refactor: split integration module

This commit is contained in:
Ogulcan Celik
2026-06-25 20:50:13 +03:00
parent 3c7327c267
commit 12cce7deeb
11 changed files with 6429 additions and 6313 deletions
+524
View File
@@ -0,0 +1,524 @@
use std::io;
use super::registry::{integration_target_label, integration_target_supported};
use super::targets::{
install_claude, install_codex, install_copilot, install_cursor, install_devin, install_droid,
install_hermes, install_kilo, install_kimi, install_omp, install_opencode, install_pi,
install_qodercli, uninstall_claude, uninstall_codex, uninstall_copilot, uninstall_cursor,
uninstall_devin, uninstall_droid, uninstall_hermes, uninstall_kilo, uninstall_kimi,
uninstall_omp, uninstall_opencode, uninstall_pi, uninstall_qodercli,
};
use super::version::{agent_version_requirement, enforce_agent_version};
use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME};
pub(crate) fn install_target(
target: crate::api::schema::IntegrationTarget,
) -> io::Result<Vec<String>> {
let result = install_target_inner(target);
let outcome = if result.is_ok() { "ok" } else { "error" };
crate::logging::integration_action("install", integration_target_label(target), outcome);
result
}
fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Result<Vec<String>> {
if !integration_target_supported(target) {
return Err(io::Error::other(format!(
"{} integration is not supported on Windows",
integration_target_label(target)
)));
}
let version_warning = match agent_version_requirement(target) {
Some(requirement) => enforce_agent_version(&requirement)?,
None => None,
};
let mut messages = match target {
crate::api::schema::IntegrationTarget::Pi => {
let path = install_pi()?;
vec![format!("installed pi integration to {}", path.display())]
}
crate::api::schema::IntegrationTarget::Omp => {
let installed = install_omp()?;
let mut messages = Vec::new();
if installed.removed_legacy_pi_extension {
messages.push(format!(
"removed legacy pi integration from omp extension directory at {}",
installed
.extension_path
.with_file_name(PI_EXTENSION_INSTALL_NAME)
.display()
));
}
messages.push(format!(
"installed omp integration to {}",
installed.extension_path.display()
));
messages
}
crate::api::schema::IntegrationTarget::Claude => {
let installed = install_claude()?;
vec![
format!(
"installed claude integration hook to {}",
installed.hook_path.display()
),
format!(
"ensured claude settings at {}",
installed.settings_path.display()
),
]
}
crate::api::schema::IntegrationTarget::Codex => {
let installed = install_codex()?;
vec![
format!(
"installed codex integration hook to {}",
installed.hook_path.display()
),
format!("ensured codex hooks at {}", installed.hooks_path.display()),
format!(
"ensured codex config at {}",
installed.config_path.display()
),
]
}
crate::api::schema::IntegrationTarget::Copilot => {
let installed = install_copilot()?;
vec![
format!(
"installed copilot integration hook to {}",
installed.hook_path.display()
),
format!(
"ensured copilot settings at {}",
installed.settings_path.display()
),
]
}
crate::api::schema::IntegrationTarget::Devin => {
let installed = install_devin()?;
vec![
format!(
"installed devin integration hook to {}",
installed.hook_path.display()
),
format!(
"ensured devin settings at {}",
installed.settings_path.display()
),
]
}
crate::api::schema::IntegrationTarget::Kimi => {
let installed = install_kimi()?;
vec![
format!(
"installed kimi integration hook to {}",
installed.hook_path.display()
),
format!("ensured kimi config at {}", installed.config_path.display()),
format!("requires kimi code {KIMI_MIN_VERSION} or newer"),
]
}
crate::api::schema::IntegrationTarget::Droid => {
let installed = install_droid()?;
let mut messages = vec![
format!(
"installed droid integration hook to {}",
installed.hook_path.display()
),
format!(
"ensured droid hooks at {}",
installed.settings_path.display()
),
];
if installed.updated_legacy_hooks {
messages.push(format!(
"removed legacy herdr droid hook entries from {}",
installed.hooks_path.display()
));
}
messages
}
crate::api::schema::IntegrationTarget::Opencode => {
let installed = install_opencode()?;
vec![format!(
"installed opencode integration plugin to {}",
installed.plugin_path.display()
)]
}
crate::api::schema::IntegrationTarget::Kilo => {
let installed = install_kilo()?;
vec![format!(
"installed kilo integration plugin to {}",
installed.plugin_path.display()
)]
}
crate::api::schema::IntegrationTarget::Hermes => {
let installed = install_hermes()?;
vec![
format!(
"installed hermes integration plugin to {}",
installed.plugin_dir.display()
),
format!(
"enabled hermes plugin in {}",
installed.config_path.display()
),
]
}
crate::api::schema::IntegrationTarget::Qodercli => {
let installed = install_qodercli()?;
vec![
format!(
"installed qodercli integration hook to {}",
installed.hook_path.display()
),
format!(
"ensured qodercli settings at {}",
installed.settings_path.display()
),
]
}
crate::api::schema::IntegrationTarget::Cursor => {
let installed = install_cursor()?;
vec![
format!(
"installed cursor integration hook to {}",
installed.hook_path.display()
),
format!("updated cursor hooks at {}", installed.hooks_path.display()),
]
}
};
if let Some(warning) = version_warning {
messages.push(warning);
}
Ok(messages)
}
pub(crate) fn uninstall_target(
target: crate::api::schema::IntegrationTarget,
) -> io::Result<Vec<String>> {
let messages = match target {
crate::api::schema::IntegrationTarget::Pi => {
let result = uninstall_pi()?;
if result.removed_extension {
vec![format!(
"removed pi integration extension at {}",
result.extension_path.display()
)]
} else {
vec![format!(
"no pi integration extension found at {}",
result.extension_path.display()
)]
}
}
crate::api::schema::IntegrationTarget::Omp => {
let result = uninstall_omp()?;
if result.removed_extension {
vec![format!(
"removed omp integration extension at {}",
result.extension_path.display()
)]
} else {
vec![format!(
"no omp integration extension found at {}",
result.extension_path.display()
)]
}
}
crate::api::schema::IntegrationTarget::Claude => {
let result = uninstall_claude()?;
let mut messages = Vec::new();
if result.removed_hook_file {
messages.push(format!(
"removed claude hook at {}",
result.hook_path.display()
));
} else {
messages.push(format!(
"no claude hook found at {}",
result.hook_path.display()
));
}
if result.updated_settings {
messages.push(format!(
"removed herdr claude hook entries from {}",
result.settings_path.display()
));
} else {
messages.push(format!(
"no herdr claude hook entries found in {}",
result.settings_path.display()
));
}
messages
}
crate::api::schema::IntegrationTarget::Codex => {
let result = uninstall_codex()?;
let mut messages = Vec::new();
if result.removed_hook_file {
messages.push(format!(
"removed codex hook at {}",
result.hook_path.display()
));
} else {
messages.push(format!(
"no codex hook found at {}",
result.hook_path.display()
));
}
if result.updated_hooks {
messages.push(format!(
"removed herdr codex hook entries from {}",
result.hooks_path.display()
));
} else {
messages.push(format!(
"no herdr codex hook entries found in {}",
result.hooks_path.display()
));
}
messages.push(format!(
"left codex config unchanged at {}",
result.config_path.display()
));
messages
}
crate::api::schema::IntegrationTarget::Copilot => {
let result = uninstall_copilot()?;
let mut messages = Vec::new();
if result.removed_hook_file {
messages.push(format!(
"removed copilot hook at {}",
result.hook_path.display()
));
} else {
messages.push(format!(
"no copilot hook found at {}",
result.hook_path.display()
));
}
if result.updated_settings {
messages.push(format!(
"removed herdr copilot hook entries from {}",
result.settings_path.display()
));
} else {
messages.push(format!(
"no herdr copilot hook entries found in {}",
result.settings_path.display()
));
}
messages
}
crate::api::schema::IntegrationTarget::Devin => {
let result = uninstall_devin()?;
let mut messages = Vec::new();
if result.removed_hook_file {
messages.push(format!(
"removed devin hook at {}",
result.hook_path.display()
));
} else {
messages.push(format!(
"no devin hook found at {}",
result.hook_path.display()
));
}
if result.updated_settings {
messages.push(format!(
"removed herdr devin hook entries from {}",
result.settings_path.display()
));
} else {
messages.push(format!(
"no herdr devin hook entries found in {}",
result.settings_path.display()
));
}
messages
}
crate::api::schema::IntegrationTarget::Kimi => {
let result = uninstall_kimi()?;
let mut messages = Vec::new();
if result.removed_hook_file {
messages.push(format!(
"removed kimi hook at {}",
result.hook_path.display()
));
} else {
messages.push(format!(
"no kimi hook found at {}",
result.hook_path.display()
));
}
if result.updated_config {
messages.push(format!(
"removed herdr kimi hook entries from {}",
result.config_path.display()
));
} else {
messages.push(format!(
"no herdr kimi hook entries found in {}",
result.config_path.display()
));
}
messages
}
crate::api::schema::IntegrationTarget::Droid => {
let result = uninstall_droid()?;
let mut messages = Vec::new();
if result.removed_hook_file {
messages.push(format!(
"removed droid hook at {}",
result.hook_path.display()
));
} else {
messages.push(format!(
"no droid hook found at {}",
result.hook_path.display()
));
}
if result.updated_hooks {
messages.push(format!(
"removed legacy herdr droid hook entries from {}",
result.hooks_path.display()
));
} else {
messages.push(format!(
"no legacy herdr droid hook entries found in {}",
result.hooks_path.display()
));
}
if result.updated_settings {
messages.push(format!(
"removed herdr droid hook entries from {}",
result.settings_path.display()
));
} else {
messages.push(format!(
"no herdr droid hook entries found in {}",
result.settings_path.display()
));
}
messages
}
crate::api::schema::IntegrationTarget::Opencode => {
let result = uninstall_opencode()?;
if result.removed_plugin {
vec![format!(
"removed opencode integration plugin at {}",
result.plugin_path.display()
)]
} else {
vec![format!(
"no opencode integration plugin found at {}",
result.plugin_path.display()
)]
}
}
crate::api::schema::IntegrationTarget::Kilo => {
let result = uninstall_kilo()?;
if result.removed_plugin {
vec![format!(
"removed kilo integration plugin at {}",
result.plugin_path.display()
)]
} else {
vec![format!(
"no kilo integration plugin found at {}",
result.plugin_path.display()
)]
}
}
crate::api::schema::IntegrationTarget::Hermes => {
let result = uninstall_hermes()?;
let mut messages = Vec::new();
if result.removed_plugin_dir {
messages.push(format!(
"removed hermes integration plugin at {}",
result.plugin_dir.display()
));
} else {
messages.push(format!(
"no hermes integration plugin found at {}",
result.plugin_dir.display()
));
}
if result.updated_config {
messages.push(format!(
"disabled hermes plugin in {}",
result.config_path.display()
));
} else {
messages.push(format!(
"no hermes plugin entry found in {}",
result.config_path.display()
));
}
messages
}
crate::api::schema::IntegrationTarget::Qodercli => {
let result = uninstall_qodercli()?;
let mut messages = Vec::new();
if result.removed_hook_file {
messages.push(format!(
"removed qodercli hook at {}",
result.hook_path.display()
));
} else {
messages.push(format!(
"no qodercli hook found at {}",
result.hook_path.display()
));
}
if result.updated_settings {
messages.push(format!(
"removed herdr qodercli hook entries from {}",
result.settings_path.display()
));
} else {
messages.push(format!(
"no herdr qodercli hook entries found in {}",
result.settings_path.display()
));
}
messages
}
crate::api::schema::IntegrationTarget::Cursor => {
let result = uninstall_cursor()?;
let mut messages = Vec::new();
if result.removed_hook_file {
messages.push(format!(
"removed cursor hook at {}",
result.hook_path.display()
));
} else {
messages.push(format!(
"no cursor hook found at {}",
result.hook_path.display()
));
}
if result.updated_hooks {
messages.push(format!(
"removed herdr cursor hook entries from {}",
result.hooks_path.display()
));
} else {
messages.push(format!(
"no herdr cursor hook entries found in {}",
result.hooks_path.display()
));
}
messages
}
};
crate::logging::integration_action("uninstall", integration_target_label(target), "ok");
Ok(messages)
}
+48
View File
@@ -0,0 +1,48 @@
use std::path::Path;
pub(crate) fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
pub(crate) fn hook_command(hook_path: &Path, action: Option<&str>) -> String {
let path = hook_path.display().to_string();
#[cfg(windows)]
{
let mut command = format!(
"powershell -NoProfile -ExecutionPolicy Bypass -File {}",
windows_command_quote(&path)
);
if let Some(action) = action {
command.push(' ');
command.push_str(action);
}
command
}
#[cfg(not(windows))]
{
let mut command = format!("bash {}", shell_single_quote(&path));
if let Some(action) = action {
command.push(' ');
command.push_str(action);
}
command
}
}
pub(crate) fn legacy_bash_hook_command(hook_path: &Path, action: Option<&str>) -> String {
let mut command = format!(
"bash {}",
shell_single_quote(&hook_path.display().to_string())
);
if let Some(action) = action {
command.push(' ');
command.push_str(action);
}
command
}
#[cfg(windows)]
fn windows_command_quote(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\\\""))
}
+809
View File
@@ -0,0 +1,809 @@
use std::io;
use std::path::Path;
use serde_json::{json, Map, Value};
use super::command::{hook_command, legacy_bash_hook_command};
#[cfg(windows)]
use super::file_ops::legacy_bash_hook_path;
use super::{
HERMES_PLUGIN_INSTALL_NAME, KIMI_CONFIG_BLOCK_BEGIN, KIMI_CONFIG_BLOCK_END, KIMI_HOOK_EVENTS,
};
pub(crate) 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()
))
})
}
pub(crate) 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()
))
})
}
pub(crate) 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(())
}
// Claude and Codex use nested hook groups:
// { "matcher": "...", "hooks": [{ "type": "command", ... }] }
// Copilot uses the flatter settings shape:
// { "type": "command", "matcher": "...", "bash": "...", ... }
// Keep the helpers separate so install/uninstall preserves unrelated hooks in
// each agent's native format instead of normalizing user configuration.
pub(crate) fn ensure_direct_command_hook(
hooks: &mut Map<String, Value>,
event: &str,
command: String,
timeout_sec: 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 command_field = direct_command_field();
if let Some(entry) = entries.iter_mut().find(|entry| {
entry.get("type").and_then(Value::as_str) == Some("command")
&& is_matching_direct_command_entry(entry, command.as_str())
}) {
let Some(entry_object) = entry.as_object_mut() else {
return Ok(());
};
entry_object.remove("command");
entry_object.remove("bash");
entry_object.remove("powershell");
entry_object.insert(command_field.to_string(), Value::String(command.clone()));
entry_object.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into()));
match matcher {
Some(matcher) => {
entry_object.insert("matcher".to_string(), Value::String(matcher.to_string()));
}
None => {
entry_object.remove("matcher");
}
}
return Ok(());
}
let mut entry = Map::new();
entry.insert("type".to_string(), Value::String("command".to_string()));
if let Some(matcher) = matcher {
entry.insert("matcher".to_string(), Value::String(matcher.to_string()));
}
entry.insert(command_field.to_string(), Value::String(command));
entry.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into()));
entries.push(Value::Object(entry));
Ok(())
}
pub(crate) fn direct_command_field() -> &'static str {
if cfg!(windows) {
"powershell"
} else {
"bash"
}
}
pub(crate) fn is_matching_direct_command_entry(entry: &Value, command: &str) -> bool {
entry.get("command").and_then(Value::as_str) == Some(command)
|| entry.get("bash").and_then(Value::as_str) == Some(command)
|| entry.get("powershell").and_then(Value::as_str) == Some(command)
}
pub(crate) 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)
}
pub(crate) fn remove_direct_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 before = entries.len();
entries.retain(|entry| {
!(entry.get("type").and_then(Value::as_str) == Some("command")
&& is_matching_direct_command_entry(entry, command))
});
let removed = entries.len() != before;
if entries.is_empty() {
hooks.remove(event);
}
Ok(removed)
}
// Cursor hooks.json uses the minimal shape `{ "command": "..." }` documented at
// https://cursor.com/docs/hooks. Keep this separate from the nested codex and
// flat copilot helpers so install/uninstall does not rewrite unrelated hooks.
pub(crate) fn ensure_simple_command_hook(
hooks: &mut Map<String, Value>,
event: &str,
command: String,
) -> 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")))?;
if entries
.iter()
.any(|entry| entry.get("command").and_then(Value::as_str) == Some(command.as_str()))
{
return Ok(());
}
entries.push(json!({ "command": command }));
Ok(())
}
pub(crate) fn remove_simple_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 before = entries.len();
entries.retain(|entry| entry.get("command").and_then(Value::as_str) != Some(command));
let removed = entries.len() != before;
if entries.is_empty() {
hooks.remove(event);
}
Ok(removed)
}
pub(crate) fn remove_hook_commands(
hooks: &mut Map<String, Value>,
event: &str,
hook_path: &Path,
action: Option<&str>,
) -> io::Result<bool> {
let mut removed = false;
for command in hook_command_variants(hook_path, action) {
removed |= remove_command_hook(hooks, event, &command)?;
}
Ok(removed)
}
pub(crate) fn remove_direct_hook_commands(
hooks: &mut Map<String, Value>,
event: &str,
hook_path: &Path,
action: Option<&str>,
) -> io::Result<bool> {
let mut removed = false;
for command in hook_command_variants(hook_path, action) {
removed |= remove_direct_command_hook(hooks, event, &command)?;
}
Ok(removed)
}
pub(crate) fn hook_command_variants(hook_path: &Path, action: Option<&str>) -> Vec<String> {
let mut commands = vec![hook_command(hook_path, action)];
push_unique_command(&mut commands, legacy_bash_hook_command(hook_path, action));
#[cfg(windows)]
{
push_unique_command(
&mut commands,
legacy_bash_hook_command(&legacy_bash_hook_path(hook_path), action),
);
}
commands
}
pub(crate) fn push_unique_command(commands: &mut Vec<String>, command: String) {
if !commands.iter().any(|existing| existing == &command) {
commands.push(command);
}
}
pub(crate) 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)
}
pub(crate) fn ensure_hermes_plugin_enabled(content: &str) -> String {
update_hermes_enabled_plugin(content, true)
}
pub(crate) fn remove_hermes_plugin_enabled(content: &str) -> String {
update_hermes_enabled_plugin(content, false)
}
pub(crate) fn update_hermes_enabled_plugin(content: &str, enabled: bool) -> String {
let trailing_newline = content.ends_with('\n');
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let Some(plugins_index) = top_level_yaml_key_index(&lines, "plugins") else {
if !enabled {
return content.to_string();
}
let mut result = content.trim_end_matches('\n').to_string();
if !result.is_empty() {
result.push('\n');
}
result.push_str("plugins:\n enabled:\n - herdr-agent-state\n");
return result;
};
let plugins_end =
next_top_level_yaml_key_index(&lines, plugins_index + 1).unwrap_or(lines.len());
let plugins_inline_items = yaml_key_value_at_indent(&lines[plugins_index], 0, "plugins")
.and_then(yaml_flow_sequence_items);
let enabled_index = lines[plugins_index + 1..plugins_end]
.iter()
.position(|line| yaml_key_at_indent(line, 2) == Some("enabled"))
.map(|offset| plugins_index + 1 + offset);
let flat_list_start = lines[plugins_index + 1..plugins_end]
.iter()
.position(|line| yaml_list_item_value_at_indent(line, 2).is_some())
.map(|offset| plugins_index + 1 + offset);
if let Some(enabled_index) = enabled_index {
let line = lines[enabled_index].trim();
if line == "enabled: []" || line == "enabled: [] # herdr" {
if enabled {
lines[enabled_index] = " enabled:".to_string();
lines.insert(enabled_index + 1, " - herdr-agent-state".to_string());
}
return join_yaml_lines(lines, trailing_newline);
}
let list_start = enabled_index + 1;
let list_end = lines[list_start..plugins_end]
.iter()
.position(|line| {
yaml_indent(line).is_some_and(|indent| indent <= 2) && yaml_key_name(line).is_some()
})
.map(|offset| list_start + offset)
.unwrap_or(plugins_end);
let existing_item_index = lines[list_start..list_end]
.iter()
.position(|line| yaml_list_item_matches(line, HERMES_PLUGIN_INSTALL_NAME))
.map(|offset| list_start + offset);
match (enabled, existing_item_index) {
(true, Some(_)) | (false, None) => return content.to_string(),
(true, None) => lines.insert(list_start, " - herdr-agent-state".to_string()),
(false, Some(index)) => {
lines.remove(index);
}
}
return join_yaml_lines(lines, trailing_newline);
}
if let Some(mut items) = plugins_inline_items {
let existing_item_index = items
.iter()
.position(|item| item == HERMES_PLUGIN_INSTALL_NAME);
match (enabled, existing_item_index) {
(true, Some(_)) | (false, None) => return content.to_string(),
(true, None) => items.insert(0, HERMES_PLUGIN_INSTALL_NAME.to_string()),
(false, Some(index)) => {
items.remove(index);
}
}
let replacement = hermes_flat_plugin_lines(&items);
lines.splice(plugins_index..plugins_end, replacement);
return join_yaml_lines(lines, trailing_newline);
}
if let Some(flat_list_start) = flat_list_start {
let existing_item_index = lines[plugins_index + 1..plugins_end]
.iter()
.position(|line| yaml_list_item_matches_at_indent(line, 2, HERMES_PLUGIN_INSTALL_NAME))
.map(|offset| plugins_index + 1 + offset);
match (enabled, existing_item_index) {
(true, Some(_)) | (false, None) => return content.to_string(),
(true, None) => lines.insert(flat_list_start, " - herdr-agent-state".to_string()),
(false, Some(index)) => {
lines.remove(index);
}
}
return join_yaml_lines(lines, trailing_newline);
}
if enabled {
lines.insert(plugins_index + 1, " enabled:".to_string());
lines.insert(plugins_index + 2, " - herdr-agent-state".to_string());
return join_yaml_lines(lines, trailing_newline);
}
content.to_string()
}
pub(crate) fn hermes_flat_plugin_lines(items: &[String]) -> Vec<String> {
if items.is_empty() {
return vec!["plugins: []".to_string()];
}
let mut lines = vec!["plugins:".to_string()];
lines.extend(items.iter().map(|item| format!(" - {item}")));
lines
}
pub(crate) fn top_level_yaml_key_index(lines: &[String], key: &str) -> Option<usize> {
lines
.iter()
.position(|line| yaml_key_at_indent(line, 0) == Some(key))
}
pub(crate) fn next_top_level_yaml_key_index(lines: &[String], start: usize) -> Option<usize> {
lines[start..]
.iter()
.position(|line| yaml_indent(line) == Some(0) && yaml_key_name(line).is_some())
.map(|offset| start + offset)
}
pub(crate) fn yaml_key_at_indent(line: &str, indent: usize) -> Option<&str> {
if yaml_indent(line)? != indent {
return None;
}
yaml_key_name(line)
}
pub(crate) fn yaml_key_value_at_indent<'a>(
line: &'a str,
indent: usize,
key: &str,
) -> Option<&'a str> {
if yaml_indent(line)? != indent {
return None;
}
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
return None;
}
let (line_key, value) = trimmed.split_once(':')?;
(line_key.trim() == key).then_some(value.trim())
}
pub(crate) fn yaml_key_name(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('-') {
return None;
}
let (key, _) = trimmed.split_once(':')?;
let key = key.trim();
(!key.is_empty()).then_some(key)
}
pub(crate) fn yaml_indent(line: &str) -> Option<usize> {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
Some(line.len() - trimmed.len())
}
pub(crate) fn yaml_list_item_value(line: &str) -> Option<&str> {
line.trim().strip_prefix("- ").map(str::trim)
}
pub(crate) fn yaml_list_item_matches(line: &str, value: &str) -> bool {
yaml_list_item_value(line).is_some_and(|item| yaml_scalar_value(item) == value)
}
pub(crate) fn yaml_list_item_value_at_indent(line: &str, indent: usize) -> Option<&str> {
if yaml_indent(line)? != indent {
return None;
}
yaml_list_item_value(line)
}
pub(crate) fn yaml_list_item_matches_at_indent(line: &str, indent: usize, value: &str) -> bool {
yaml_list_item_value_at_indent(line, indent)
.is_some_and(|item| yaml_scalar_value(item) == value)
}
pub(crate) fn yaml_flow_sequence_items(value: &str) -> Option<Vec<String>> {
let value = strip_yaml_inline_comment(value).trim();
let inner = value.strip_prefix('[')?.strip_suffix(']')?.trim();
if inner.is_empty() {
return Some(Vec::new());
}
let mut items = Vec::new();
let mut current = String::new();
let mut quote = None;
let mut escaped = false;
for ch in inner.chars() {
if let Some(quote_char) = quote {
current.push(ch);
if quote_char == '"' && ch == '\\' && !escaped {
escaped = true;
continue;
}
if ch == quote_char && !escaped {
quote = None;
}
escaped = false;
continue;
}
match ch {
'"' | '\'' => {
quote = Some(ch);
current.push(ch);
}
',' => {
items.push(yaml_scalar_value(&current));
current.clear();
}
_ => current.push(ch),
}
}
if quote.is_some() {
return None;
}
items.push(yaml_scalar_value(&current));
Some(items)
}
pub(crate) fn yaml_scalar_value(value: &str) -> String {
let value = strip_yaml_inline_comment(value).trim();
if value.len() >= 2 {
let bytes = value.as_bytes();
let quoted = (bytes[0] == b'"' && bytes[value.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[value.len() - 1] == b'\'');
if quoted {
return value[1..value.len() - 1].to_string();
}
}
value.to_string()
}
pub(crate) fn strip_yaml_inline_comment(value: &str) -> &str {
let mut quote = None;
let mut escaped = false;
for (index, ch) in value.char_indices() {
if let Some(quote_char) = quote {
if quote_char == '"' && ch == '\\' && !escaped {
escaped = true;
continue;
}
if ch == quote_char && !escaped {
quote = None;
}
escaped = false;
continue;
}
match ch {
'"' | '\'' => quote = Some(ch),
'#' if index == 0 || value[..index].ends_with(char::is_whitespace) => {
return value[..index].trim_end();
}
_ => {}
}
}
value
}
pub(crate) fn join_yaml_lines(lines: Vec<String>, trailing_newline: bool) -> String {
let mut result = lines.join("\n");
if trailing_newline || result.is_empty() {
result.push('\n');
}
result
}
pub(crate) 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 !in_top_level_features {
continue;
}
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 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;
}
join_toml_lines(lines, trailing_newline)
}
pub(crate) fn build_kimi_config_with_hooks(content: &str, hook_path: &Path) -> String {
let mut result = remove_kimi_config_block(content)
.trim_end_matches('\n')
.to_string();
if !result.is_empty() {
result.push('\n');
result.push('\n');
}
result.push_str(KIMI_CONFIG_BLOCK_BEGIN);
result.push('\n');
for (event, action) in KIMI_HOOK_EVENTS {
result.push_str(&kimi_hook_table(event, hook_path, action));
}
result.push_str(KIMI_CONFIG_BLOCK_END);
result.push('\n');
result
}
pub(crate) fn kimi_hook_table(event: &str, hook_path: &Path, action: &str) -> String {
let command = hook_command(hook_path, Some(action));
format!(
"[[hooks]]\nevent = {}\ncommand = {}\ntimeout = 10\n\n",
toml_basic_string(event),
toml_basic_string(&command)
)
}
pub(crate) fn remove_kimi_config_block(content: &str) -> String {
let trailing_newline = content.ends_with('\n');
let mut lines = Vec::new();
let mut in_block = false;
let mut removed_block = false;
for line in content.lines() {
if line.trim() == KIMI_CONFIG_BLOCK_BEGIN {
in_block = true;
removed_block = true;
continue;
}
if in_block {
if line.trim() == KIMI_CONFIG_BLOCK_END {
in_block = false;
}
continue;
}
lines.push(line.to_string());
}
if !removed_block {
return content.to_string();
}
let mut result = join_toml_lines(lines, trailing_newline);
while result.ends_with("\n\n") {
result.pop();
}
if result == "\n" {
String::new()
} else {
result
}
}
pub(crate) fn toml_basic_string(value: &str) -> String {
let mut result = String::with_capacity(value.len() + 2);
result.push('"');
for ch in value.chars() {
match ch {
'"' => result.push_str("\\\""),
'\\' => result.push_str("\\\\"),
'\u{08}' => result.push_str("\\b"),
'\t' => result.push_str("\\t"),
'\n' => result.push_str("\\n"),
'\u{0c}' => result.push_str("\\f"),
'\r' => result.push_str("\\r"),
ch if ch <= '\u{1f}' || ch == '\u{7f}' => {
result.push_str(&format!("\\u{:04X}", ch as u32));
}
ch => result.push(ch),
}
}
result.push('"');
result
}
pub(crate) 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
}
pub(crate) 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)
}
pub(crate) 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('=')
}
+156
View File
@@ -0,0 +1,156 @@
use std::io;
use std::path::PathBuf;
#[cfg(test)]
use std::sync::{Mutex, MutexGuard, OnceLock};
use portable_pty::CommandBuilder;
pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID";
pub(crate) const HERDR_TAB_ID_ENV_VAR: &str = "HERDR_TAB_ID";
pub(crate) const HERDR_WORKSPACE_ID_ENV_VAR: &str = "HERDR_WORKSPACE_ID";
pub(crate) const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR";
pub(crate) const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
pub(crate) const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
pub(crate) const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME";
pub(crate) const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME";
pub(crate) const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR";
pub(crate) const CURSOR_CONFIG_DIR_ENV_VAR: &str = "CURSOR_CONFIG_DIR";
pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) {
cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path());
}
pub(crate) fn pi_extension_dir() -> io::Result<PathBuf> {
Ok(
config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".pi", "agent"])?
.join("extensions"),
)
}
pub(crate) fn omp_extension_dir() -> io::Result<PathBuf> {
Ok(
config_dir_from_env_or_home(PI_CODING_AGENT_DIR_ENV_VAR, &[".omp", "agent"])?
.join("extensions"),
)
}
pub(crate) fn claude_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(CLAUDE_CONFIG_DIR_ENV_VAR, &[".claude"])
}
pub(crate) fn codex_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(CODEX_HOME_ENV_VAR, &[".codex"])
}
pub(crate) fn kimi_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(KIMI_CODE_HOME_ENV_VAR, &[".kimi-code"])
}
pub(crate) fn copilot_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"])
}
pub(crate) fn devin_dir() -> io::Result<PathBuf> {
if let Some(value) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
return expand_tilde_path(PathBuf::from(value)).map(|path| path.join("devin"));
}
Ok(home_dir()?.join(".config").join("devin"))
}
pub(crate) fn droid_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".factory"))
}
pub(crate) 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)
}
pub(crate) 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)
}
pub(crate) fn opencode_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".config/opencode"))
}
pub(crate) fn kilo_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".config/kilo"))
}
pub(crate) fn hermes_dir() -> io::Result<PathBuf> {
Ok(home_dir()?.join(".hermes"))
}
pub(crate) fn hermes_plugin_dir() -> io::Result<PathBuf> {
Ok(hermes_dir()?
.join("plugins")
.join(super::HERMES_PLUGIN_INSTALL_NAME))
}
pub(crate) fn qodercli_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(QODERCLI_CONFIG_DIR_ENV_VAR, &[".qoder"])
}
pub(crate) fn cursor_dir() -> io::Result<PathBuf> {
config_dir_from_env_or_home(CURSOR_CONFIG_DIR_ENV_VAR, &[".cursor"])
}
pub(crate) fn home_dir() -> io::Result<PathBuf> {
if let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) {
return Ok(PathBuf::from(home));
}
#[cfg(windows)]
{
if let Some(profile) = std::env::var_os("USERPROFILE").filter(|value| !value.is_empty()) {
return Ok(PathBuf::from(profile));
}
if let (Some(drive), Some(path)) = (
std::env::var_os("HOMEDRIVE").filter(|value| !value.is_empty()),
std::env::var_os("HOMEPATH").filter(|value| !value.is_empty()),
) {
let mut home = PathBuf::from(drive);
home.push(path);
return Ok(home);
}
}
Err(io::Error::other(
"home directory is not set; cannot locate home directory",
))
}
#[cfg(test)]
pub(crate) fn integration_env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
+59
View File
@@ -0,0 +1,59 @@
use std::fs;
use std::io;
use std::path::Path;
pub(crate) 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),
}
}
#[cfg(windows)]
pub(crate) fn legacy_bash_hook_path(hook_path: &Path) -> std::path::PathBuf {
hook_path.with_file_name("herdr-agent-state.sh")
}
#[cfg(windows)]
pub(crate) fn remove_legacy_bash_hook_file(hook_path: &Path) -> io::Result<bool> {
let legacy_path = legacy_bash_hook_path(hook_path);
let content = match fs::read_to_string(&legacy_path) {
Ok(content) => content,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(err) => return Err(err),
};
if content.contains("HERDR_INTEGRATION_ID=") {
fs::remove_file(legacy_path)?;
return Ok(true);
}
Ok(false)
}
#[cfg(not(windows))]
pub(crate) fn remove_legacy_bash_hook_file(_hook_path: &Path) -> io::Result<bool> {
Ok(false)
}
pub(crate) fn remove_dir_all_if_exists(path: &Path) -> io::Result<bool> {
match fs::remove_dir_all(path) {
Ok(()) => Ok(true),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err),
}
}
pub(crate) 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(())
}
+21 -6313
View File
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use super::env::*;
pub(crate) fn integration_target_label(
target: crate::api::schema::IntegrationTarget,
) -> &'static str {
match target {
crate::api::schema::IntegrationTarget::Pi => "pi",
crate::api::schema::IntegrationTarget::Omp => "omp",
crate::api::schema::IntegrationTarget::Claude => "claude",
crate::api::schema::IntegrationTarget::Codex => "codex",
crate::api::schema::IntegrationTarget::Copilot => "copilot",
crate::api::schema::IntegrationTarget::Devin => "devin",
crate::api::schema::IntegrationTarget::Droid => "droid",
crate::api::schema::IntegrationTarget::Kimi => "kimi",
crate::api::schema::IntegrationTarget::Opencode => "opencode",
crate::api::schema::IntegrationTarget::Kilo => "kilo",
crate::api::schema::IntegrationTarget::Hermes => "hermes",
crate::api::schema::IntegrationTarget::Qodercli => "qodercli",
crate::api::schema::IntegrationTarget::Cursor => "cursor",
}
}
pub(crate) fn integration_target_command(
target: crate::api::schema::IntegrationTarget,
) -> &'static str {
integration_target_command_names(target)[0]
}
pub(crate) fn integration_target_command_names(
target: crate::api::schema::IntegrationTarget,
) -> &'static [&'static str] {
match target {
crate::api::schema::IntegrationTarget::Pi => &["pi"],
crate::api::schema::IntegrationTarget::Omp => &["omp"],
crate::api::schema::IntegrationTarget::Claude => &["claude"],
crate::api::schema::IntegrationTarget::Codex => &["codex"],
crate::api::schema::IntegrationTarget::Copilot => &["copilot"],
crate::api::schema::IntegrationTarget::Devin => &["devin"],
crate::api::schema::IntegrationTarget::Droid => &["droid"],
crate::api::schema::IntegrationTarget::Kimi => &["kimi"],
crate::api::schema::IntegrationTarget::Opencode => &["opencode"],
crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"],
crate::api::schema::IntegrationTarget::Hermes => &["hermes"],
crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(),
crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(),
}
}
pub(crate) fn cursor_command_names() -> &'static [&'static str] {
&["cursor-agent"]
}
pub(crate) fn integration_target_supported(target: crate::api::schema::IntegrationTarget) -> bool {
#[cfg(windows)]
{
matches!(
target,
crate::api::schema::IntegrationTarget::Claude
| crate::api::schema::IntegrationTarget::Codex
| crate::api::schema::IntegrationTarget::Copilot
| crate::api::schema::IntegrationTarget::Droid
| crate::api::schema::IntegrationTarget::Kimi
| crate::api::schema::IntegrationTarget::Qodercli
)
}
#[cfg(not(windows))]
{
let _ = target;
true
}
}
pub(crate) fn integration_target_available(target: crate::api::schema::IntegrationTarget) -> bool {
if !integration_target_supported(target) {
return false;
}
integration_target_command_names(target)
.iter()
.any(|command| command_available(command))
|| integration_target_install_layout_available(target)
}
#[cfg(windows)]
pub(crate) fn qodercli_command_names() -> &'static [&'static str] {
&["qodercli", "qoder", "qoderclicn", "qodercn"]
}
#[cfg(not(windows))]
pub(crate) fn qodercli_command_names() -> &'static [&'static str] {
&["qodercli"]
}
pub(crate) fn integration_target_install_layout_available(
target: crate::api::schema::IntegrationTarget,
) -> bool {
match target {
crate::api::schema::IntegrationTarget::Codex => codex_standalone_binary_available(),
crate::api::schema::IntegrationTarget::Hermes => hermes_install_layout_available(),
_ => false,
}
}
pub(crate) fn command_available(command: &str) -> bool {
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| {
command_path_candidates(&dir, command)
.into_iter()
.any(|path| executable_file_exists(&path))
})
}
pub(crate) fn command_path_candidates(dir: &Path, command: &str) -> Vec<PathBuf> {
let base = dir.join(command);
#[cfg(not(windows))]
{
vec![base]
}
#[cfg(windows)]
{
if Path::new(command).extension().is_some() {
return vec![base];
}
let mut candidates = vec![base];
for extension in [".exe", ".cmd", ".bat", ".ps1"] {
candidates.push(dir.join(format!("{command}{extension}")));
}
candidates
}
}
pub(crate) fn executable_file_exists(path: &Path) -> bool {
let Ok(metadata) = path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
pub(crate) fn codex_standalone_binary_available() -> bool {
let Ok(releases_dir) =
codex_dir().map(|dir| dir.join("packages").join("standalone").join("releases"))
else {
return false;
};
let Ok(entries) = fs::read_dir(releases_dir) else {
return false;
};
entries.filter_map(Result::ok).any(|entry| {
executable_file_exists(&entry.path().join("bin").join(codex_executable_name()))
})
}
pub(crate) fn codex_executable_name() -> &'static str {
if cfg!(windows) {
"codex.exe"
} else {
"codex"
}
}
pub(crate) fn hermes_install_layout_available() -> bool {
#[cfg(windows)]
{
let Some(local_app_data) =
std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty())
else {
return false;
};
let dir = PathBuf::from(local_app_data).join("hermes");
[
dir.join("hermes.exe"),
dir.join("bin").join("hermes.exe"),
dir.join("Scripts").join("hermes.exe"),
]
.into_iter()
.any(|path| executable_file_exists(&path))
}
#[cfg(not(windows))]
{
false
}
}
pub(crate) fn installed_integration_statuses() -> Vec<super::IntegrationStatus> {
integration_specs()
.into_iter()
.filter_map(|(target, path, expected_version)| {
if !integration_target_supported(target) {
return None;
}
Some(integration_status_at(target, path.ok()?, expected_version))
})
.collect()
}
pub(crate) fn integration_recommendations() -> Vec<super::IntegrationRecommendation> {
integration_specs()
.into_iter()
.filter_map(|(target, path, expected_version)| {
if !integration_target_supported(target) {
return None;
}
let path = path.ok()?;
let status = integration_status_at(target, path.clone(), expected_version);
Some(super::IntegrationRecommendation {
target,
label: integration_target_label(target),
command: integration_target_command(target),
available: integration_target_available(target)
|| status.state != super::IntegrationStatusKind::NotInstalled,
path,
state: status.state,
})
})
.collect()
}
pub(crate) fn outdated_installed_integrations() -> Vec<super::IntegrationStatus> {
installed_integration_statuses()
.into_iter()
.filter(|status| status.state == super::IntegrationStatusKind::Outdated)
.collect()
}
fn integration_specs() -> [(
crate::api::schema::IntegrationTarget,
io::Result<PathBuf>,
u32,
); 13] {
[
(
crate::api::schema::IntegrationTarget::Pi,
pi_extension_dir().map(|dir| dir.join(super::PI_EXTENSION_INSTALL_NAME)),
super::PI_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Omp,
omp_extension_dir().map(|dir| dir.join(super::OMP_EXTENSION_INSTALL_NAME)),
super::OMP_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Claude,
claude_dir().map(|dir| dir.join("hooks").join(super::CLAUDE_HOOK_INSTALL_NAME)),
super::CLAUDE_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Codex,
codex_dir().map(|dir| dir.join(super::CODEX_HOOK_INSTALL_NAME)),
super::CODEX_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Copilot,
copilot_dir().map(|dir| dir.join("hooks").join(super::COPILOT_HOOK_INSTALL_NAME)),
super::COPILOT_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Devin,
devin_dir().map(|dir| dir.join(super::DEVIN_HOOK_INSTALL_NAME)),
super::DEVIN_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Droid,
droid_dir().map(|dir| dir.join("hooks").join(super::DROID_HOOK_INSTALL_NAME)),
super::DROID_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Kimi,
kimi_dir().map(|dir| dir.join("hooks").join(super::KIMI_HOOK_INSTALL_NAME)),
super::KIMI_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Opencode,
opencode_dir().map(|dir| {
dir.join("plugins")
.join(super::OPENCODE_PLUGIN_INSTALL_NAME)
}),
super::OPENCODE_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Kilo,
kilo_dir().map(|dir| dir.join("plugin").join(super::KILO_PLUGIN_INSTALL_NAME)),
super::KILO_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Hermes,
hermes_plugin_dir().map(|dir| dir.join(super::HERMES_PLUGIN_INIT_INSTALL_NAME)),
super::HERMES_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Qodercli,
qodercli_dir().map(|dir| dir.join("hooks").join(super::QODERCLI_HOOK_INSTALL_NAME)),
super::QODERCLI_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Cursor,
cursor_dir().map(|dir| dir.join(super::CURSOR_HOOK_INSTALL_NAME)),
super::CURSOR_INTEGRATION_VERSION,
),
]
}
pub(crate) fn integration_update_instructions(
targets: &[crate::api::schema::IntegrationTarget],
) -> String {
let commands: Vec<String> = targets
.iter()
.map(|target| {
format!(
"`herdr integration install {}`",
integration_target_label(*target)
)
})
.collect();
match commands.as_slice() {
[] => String::new(),
[command] => format!("run {command}"),
[rest @ .., last] => format!("run {} and {last}", rest.join(", ")),
}
}
pub(crate) fn print_outdated_update_notice() -> bool {
let outdated = outdated_installed_integrations();
if outdated.is_empty() {
return false;
}
let targets = outdated
.iter()
.map(|integration| integration.target)
.collect::<Vec<_>>();
eprintln!(
"installed herdr integrations need updating; {}.",
integration_update_instructions(&targets).replace('`', "")
);
true
}
pub(crate) fn integration_status_at(
target: crate::api::schema::IntegrationTarget,
path: PathBuf,
expected_version: u32,
) -> super::IntegrationStatus {
if !path.is_file() {
return super::IntegrationStatus {
target,
path,
state: super::IntegrationStatusKind::NotInstalled,
installed_version: None,
expected_version,
};
}
let installed_version = fs::read_to_string(&path)
.ok()
.and_then(|content| parse_integration_version(&content));
let state = if installed_version.is_some_and(|version| version >= expected_version) {
super::IntegrationStatusKind::Current
} else {
super::IntegrationStatusKind::Outdated
};
super::IntegrationStatus {
target,
path,
state,
installed_version,
expected_version,
}
}
pub(crate) fn parse_integration_version(content: &str) -> Option<u32> {
content.lines().find_map(|line| {
let marker_line = line
.trim()
.trim_start_matches('/')
.trim_start_matches('#')
.trim();
marker_line
.strip_prefix(super::INTEGRATION_VERSION_MARKER)?
.trim()
.parse()
.ok()
})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
use std::path::PathBuf;
#[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 KimiInstallPaths {
pub hook_path: PathBuf,
pub config_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct CopilotInstallPaths {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct DevinInstallPaths {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct DroidInstallPaths {
pub hook_path: PathBuf,
pub hooks_path: PathBuf,
pub settings_path: PathBuf,
pub updated_legacy_hooks: bool,
}
#[derive(Debug)]
pub(crate) struct OpenCodeInstallPaths {
pub plugin_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct KiloInstallPaths {
pub plugin_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct OmpInstallPaths {
pub extension_path: PathBuf,
pub removed_legacy_pi_extension: bool,
}
#[derive(Debug)]
pub(crate) struct HermesInstallPaths {
pub plugin_dir: PathBuf,
pub config_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct QodercliInstallPaths {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct CursorInstallPaths {
pub hook_path: PathBuf,
pub hooks_path: PathBuf,
}
#[derive(Debug)]
pub(crate) struct CursorUninstallResult {
pub hook_path: PathBuf,
pub hooks_path: PathBuf,
pub removed_hook_file: bool,
pub updated_hooks: bool,
}
#[derive(Debug)]
pub(crate) struct QodercliUninstallResult {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
pub removed_hook_file: bool,
pub updated_settings: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IntegrationStatus {
pub target: crate::api::schema::IntegrationTarget,
pub path: PathBuf,
pub state: IntegrationStatusKind,
pub installed_version: Option<u32>,
pub expected_version: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IntegrationStatusKind {
NotInstalled,
Current,
Outdated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IntegrationRecommendation {
pub target: crate::api::schema::IntegrationTarget,
pub label: &'static str,
pub command: &'static str,
pub available: bool,
pub path: PathBuf,
pub state: IntegrationStatusKind,
}
impl IntegrationRecommendation {
pub fn needs_install(&self) -> bool {
self.state == IntegrationStatusKind::Outdated
|| (self.available && self.state == IntegrationStatusKind::NotInstalled)
}
pub fn status_label(&self) -> &'static str {
match (self.available, self.state) {
(_, IntegrationStatusKind::Current) => "installed",
(_, IntegrationStatusKind::Outdated) => "update available",
(true, IntegrationStatusKind::NotInstalled) => "available",
(false, IntegrationStatusKind::NotInstalled) => "not found",
}
}
}
#[derive(Debug)]
pub(crate) struct PiUninstallResult {
pub extension_path: PathBuf,
pub removed_extension: bool,
}
#[derive(Debug)]
pub(crate) struct OmpUninstallResult {
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 KimiUninstallResult {
pub hook_path: PathBuf,
pub config_path: PathBuf,
pub removed_hook_file: bool,
pub updated_config: bool,
}
#[derive(Debug)]
pub(crate) struct CopilotUninstallResult {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
pub removed_hook_file: bool,
pub updated_settings: bool,
}
#[derive(Debug)]
pub(crate) struct DevinUninstallResult {
pub hook_path: PathBuf,
pub settings_path: PathBuf,
pub removed_hook_file: bool,
pub updated_settings: bool,
}
#[derive(Debug)]
pub(crate) struct DroidUninstallResult {
pub hook_path: PathBuf,
pub hooks_path: PathBuf,
pub settings_path: PathBuf,
pub removed_hook_file: bool,
pub updated_hooks: bool,
pub updated_settings: bool,
}
#[derive(Debug)]
pub(crate) struct OpenCodeUninstallResult {
pub plugin_path: PathBuf,
pub removed_plugin: bool,
}
#[derive(Debug)]
pub(crate) struct KiloUninstallResult {
pub plugin_path: PathBuf,
pub removed_plugin: bool,
}
#[derive(Debug)]
pub(crate) struct HermesUninstallResult {
pub plugin_dir: PathBuf,
pub config_path: PathBuf,
pub removed_plugin_dir: bool,
pub updated_config: bool,
}
+89
View File
@@ -0,0 +1,89 @@
use std::io;
pub(crate) struct AgentVersionRequirement {
pub label: &'static str,
pub binary: &'static str,
pub args: &'static [&'static str],
pub min_version: &'static str,
}
pub(crate) fn agent_version_requirement(
target: crate::api::schema::IntegrationTarget,
) -> Option<AgentVersionRequirement> {
match target {
crate::api::schema::IntegrationTarget::Kimi => Some(AgentVersionRequirement {
label: "kimi code",
binary: "kimi",
args: &["--version"],
min_version: super::KIMI_MIN_VERSION,
}),
_ => None,
}
}
pub(crate) fn extract_version_triple(text: &str) -> Option<(u64, u64, u64)> {
text.split_whitespace().find_map(|token| {
let token = token.trim_start_matches('v');
let mut parts = token.splitn(3, '.');
let major: u64 = parts.next()?.parse().ok()?;
let minor: u64 = parts.next()?.parse().ok()?;
let patch: u64 = parts
.next()
.map(|rest| {
rest.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
})
.and_then(|digits| digits.parse().ok())
.unwrap_or(0);
Some((major, minor, patch))
})
}
/// Returns `Ok(None)` when the installed agent satisfies the requirement,
/// `Ok(Some(warning))` when the version cannot be determined (install
/// proceeds), and `Err` when the installed agent is too old.
pub(crate) fn enforce_agent_version(
requirement: &AgentVersionRequirement,
) -> io::Result<Option<String>> {
let probe = format!("{} {}", requirement.binary, requirement.args.join(" "));
let output = match std::process::Command::new(requirement.binary)
.args(requirement.args)
.output()
{
Ok(output) if output.status.success() => output,
_ => {
return Ok(Some(format!(
"{} could not run `{probe}` to verify the installed version; hooks require {} {} or newer",
super::INSTALL_WARNING_PREFIX,
requirement.label,
requirement.min_version
)));
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(found) = extract_version_triple(&stdout) else {
return Ok(Some(format!(
"{} could not parse the {} version from `{probe}` output; hooks require {} {} or newer",
super::INSTALL_WARNING_PREFIX,
requirement.label,
requirement.label,
requirement.min_version
)));
};
let required = extract_version_triple(requirement.min_version)
.expect("static min version must be a valid version triple");
if found < required {
return Err(io::Error::other(format!(
"{label} {}.{}.{} is too old: herdr hooks require {label} {min} or newer. upgrade {label}, then re-run install",
found.0,
found.1,
found.2,
label = requirement.label,
min = requirement.min_version
)));
}
Ok(None)
}