diff options
Diffstat (limited to 'src/integration/config_edit.rs')
| -rw-r--r-- | src/integration/config_edit.rs | 809 |
1 files changed, 809 insertions, 0 deletions
diff --git a/src/integration/config_edit.rs b/src/integration/config_edit.rs new file mode 100644 index 00000000..a1653395 --- /dev/null +++ b/src/integration/config_edit.rs @@ -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(¤t)); + current.clear(); + } + _ => current.push(ch), + } + } + + if quote.is_some() { + return None; + } + + items.push(yaml_scalar_value(¤t)); + 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('=') +} |
