mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 08:01:06 +00:00
810 lines
25 KiB
Rust
810 lines
25 KiB
Rust
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('=')
|
|
}
|