mod claude;
mod codex;
mod grok;
pub use claude::{
CLAUDE_HOOK_MARKER, ClaudeHookInstall, ClaudeHookUninstall, claude_settings_path,
heal_claude_hook_asset, install_claude_hook, install_claude_hook_into, uninstall_claude_hook,
uninstall_claude_hook_into,
};
pub use codex::{
CODEX_HOOK_MARKER, CodexHookInstall, CodexHookUninstall, codex_config_dir,
heal_codex_hook_asset, install_codex_hook, install_codex_hook_into, uninstall_codex_hook,
uninstall_codex_hook_into,
};
pub use grok::{
GROK_HOOK_MARKER, GrokHookInstall, GrokHookUninstall, grok_config_dir, install_grok_hook,
install_grok_hook_into, uninstall_grok_hook, uninstall_grok_hook_into,
};
pub fn heal_hook_assets() {
claude::heal_claude_hook_asset();
codex::heal_codex_hook_asset();
}
use jsonc_parser::ast::{
Array as AstArray, Object as AstObject, ObjectPropName, Value as AstValue,
};
use jsonc_parser::common::{Range, Ranged};
use jsonc_parser::{ParseOptions, parse_to_ast, parse_to_serde_value};
use serde_json::{Value as JsonValue, json};
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::config::Config;
const HOOK_TIMEOUT_SECS: u64 = 10;
pub(crate) fn hook_asset_path(install_name: &str) -> PathBuf {
Config::config_dir().join("hooks").join(install_name)
}
pub(crate) fn hook_command_for(hook_path: &Path) -> String {
platform_hook_command(hook_path, None, cfg!(windows))
}
pub(crate) fn hook_command_with_action(hook_path: &Path, action: &str) -> String {
platform_hook_command(hook_path, Some(action), cfg!(windows))
}
pub(crate) fn platform_hook_command(
hook_path: &Path,
action: Option<&str>,
windows: bool,
) -> String {
let text = hook_path.display().to_string();
let mut command = if windows {
format!(
"powershell -NoProfile -ExecutionPolicy Bypass -File \"{}\"",
text.replace('"', "\\\"")
)
} else if let Some(action) = action {
format!("sh {} {action}", shell_single_quote(&text))
} else {
let safe = !text.is_empty()
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_/.:=@%+".contains(c));
if safe {
text
} else {
shell_single_quote(&text)
}
};
if let Some(action) = action
&& windows
{
command.push(' ');
command.push_str(action);
}
command
}
pub(crate) fn shell_single_quote(text: &str) -> String {
format!("'{}'", text.replace('\'', r"'\''"))
}
pub(crate) fn write_hook_asset(hook_path: &Path, asset: &str) -> io::Result<()> {
if let Some(parent) = hook_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(hook_path, asset)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(hook_path, fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
pub(crate) fn remove_marked_file(path: &Path, marker: &str) -> io::Result<bool> {
if !path.is_file() {
return Ok(false);
}
if !fs::read_to_string(path)?.contains(marker) {
return Ok(false);
}
fs::remove_file(path)?;
Ok(true)
}
pub(crate) fn save_settings(settings_path: &Path, contents: &str) -> io::Result<()> {
crate::atomic_save::save_string_atomic_preserving_mode(settings_path, contents).map_err(|err| {
io::Error::other(format!(
"failed to write {}: {err:#}",
settings_path.display()
))
})
}
pub(crate) fn home_dir() -> io::Result<PathBuf> {
dirs::home_dir().ok_or_else(|| io::Error::other("Could not determine home directory"))
}
pub(crate) fn parse_options() -> ParseOptions {
ParseOptions {
allow_comments: true,
allow_trailing_commas: true,
allow_loose_object_property_names: false,
allow_missing_commas: false,
allow_single_quoted_strings: false,
allow_hexadecimal_numbers: false,
allow_unary_plus_numbers: false,
}
}
pub(crate) fn parse_root_object<'a>(
content: &'a str,
settings_path: &Path,
) -> io::Result<AstObject<'a>> {
let parsed = parse_to_ast(content, &Default::default(), &parse_options()).map_err(|err| {
io::Error::other(format!(
"failed to parse {}: {err}",
settings_path.display()
))
})?;
match parsed.value {
Some(AstValue::Object(object)) => Ok(object),
_ => Err(io::Error::other(format!(
"config at {} must be a JSON object",
settings_path.display()
))),
}
}
pub(crate) fn parse_serde(content: &str, settings_path: &Path) -> io::Result<JsonValue> {
parse_to_serde_value(content, &parse_options()).map_err(|err| {
io::Error::other(format!(
"failed to parse {}: {err}",
settings_path.display()
))
})
}
pub(crate) fn reject_duplicate_keys(object: &AstObject, settings_path: &Path) -> io::Result<()> {
let mut names = std::collections::HashSet::new();
for property in &object.properties {
let Some(name) = prop_name_text(&property.name) else {
return Err(io::Error::other("JSON object property is missing a name"));
};
if !names.insert(name) {
return Err(io::Error::other(format!(
"duplicate key \"{name}\" in {} — fix the file before installing",
settings_path.display()
)));
}
}
for property in &object.properties {
match &property.value {
AstValue::Object(inner) => reject_duplicate_keys(inner, settings_path)?,
AstValue::Array(array) => {
for element in &array.elements {
if let AstValue::Object(inner) = element {
reject_duplicate_keys(inner, settings_path)?;
}
}
}
_ => {}
}
}
Ok(())
}
pub(crate) fn prop_name_text<'a>(name: &'a ObjectPropName<'a>) -> Option<&'a str> {
match name {
ObjectPropName::String(lit) => Some(lit.value.as_ref()),
ObjectPropName::Word(word) => Some(word.value),
}
}
pub(crate) fn string_value<'a>(value: &'a AstValue<'_>) -> Option<&'a str> {
match value {
AstValue::StringLit(lit) => Some(lit.value.as_ref()),
_ => None,
}
}
pub(crate) fn canonical_entry_json(matcher: Option<&str>, command: &str) -> String {
let command_json = serde_json::to_string(command).expect("command is serializable");
let matcher_json = match matcher {
Some(matcher) => format!(
"\"matcher\":{},",
serde_json::to_string(matcher).expect("matcher is serializable",)
),
None => String::new(),
};
format!(
"{{{matcher_json}\"hooks\":[{{\"type\":\"command\",\"command\":{command_json},\"timeout\":{HOOK_TIMEOUT_SECS}}}]}}"
)
}
pub(crate) fn canonical_entry_value(matcher: Option<&str>, command: &str) -> JsonValue {
let mut entry = json!({});
if let Some(matcher) = matcher {
entry["matcher"] = json!(matcher);
}
entry["hooks"] = json!([{
"type": "command",
"command": command,
"timeout": HOOK_TIMEOUT_SECS,
}]);
entry
}
pub(crate) fn has_command(
content: &str,
settings_path: &Path,
command: &str,
event: &str,
) -> io::Result<bool> {
let value = parse_serde(content, settings_path)?;
Ok(value
.get("hooks")
.and_then(|hooks| hooks.get(event))
.and_then(JsonValue::as_array)
.is_some_and(|groups| {
groups.iter().any(|group| {
group
.get("hooks")
.and_then(JsonValue::as_array)
.is_some_and(|entries| {
entries.iter().any(|entry| {
entry.get("command").and_then(JsonValue::as_str) == Some(command)
})
})
})
}))
}
pub(crate) fn merge_hook_event(
content: &str,
settings_path: &Path,
command: &str,
matcher: Option<&str>,
event: &str,
) -> io::Result<Option<String>> {
let root = parse_root_object(content, settings_path)?;
reject_duplicate_keys(&root, settings_path)?;
if has_command(content, settings_path, command, event)? {
return Ok(None);
}
let canonical = canonical_entry_json(matcher, command);
let updated = match root.get_object("hooks") {
Some(hooks) => match hooks.get_array(event) {
Some(event_array) => append_array_element(content, event_array, &canonical),
None => append_object_property(content, hooks, event, &format!("[{canonical}]")),
},
None => append_object_property(
content,
&root,
"hooks",
&format!("{{\"{event}\":[{canonical}]}}"),
),
};
let mut desired = parse_serde(content, settings_path)?;
match desired
.get_mut("hooks")
.and_then(|hooks| hooks.get_mut(event))
.and_then(JsonValue::as_array_mut)
{
Some(entries) => entries.push(canonical_entry_value(matcher, command)),
None => {
let Some(root_object) = desired.as_object_mut() else {
return Err(io::Error::other(format!(
"config at {} must be a JSON object",
settings_path.display()
)));
};
match root_object.get_mut("hooks") {
Some(hooks_value) => {
let Some(hooks_object) = hooks_value.as_object_mut() else {
return Err(io::Error::other(format!(
"hooks at {} must be a JSON object",
settings_path.display()
)));
};
hooks_object.insert(
event.to_string(),
json!([canonical_entry_value(matcher, command)]),
);
}
None => {
let mut fresh_hooks = serde_json::Map::new();
fresh_hooks.insert(
event.to_string(),
json!([canonical_entry_value(matcher, command)]),
);
root_object.insert("hooks".to_string(), JsonValue::Object(fresh_hooks));
}
}
}
}
verify_update(updated, settings_path, &desired).map(Some)
}
pub(crate) fn unmerge_hook_entries(
content: &str,
settings_path: &Path,
entries: &[(&str, &str)],
) -> io::Result<Option<String>> {
let root = parse_root_object(content, settings_path)?;
reject_duplicate_keys(&root, settings_path)?;
let original = parse_serde(content, settings_path)?;
let events: Vec<&str> = entries.iter().map(|(event, _)| *event).collect();
let mut updated = content.to_string();
let mut changed = false;
while let Some(cut) = find_next_our_cut(&updated, settings_path, entries)? {
let mut next = String::with_capacity(updated.len());
next.push_str(&updated[..cut.start]);
next.push_str(&updated[cut.end..]);
updated = next;
changed = true;
}
if !changed {
return Ok(None);
}
while let Some(cut) = find_next_empty_container_cut(&updated, settings_path, &events)? {
let mut next = String::with_capacity(updated.len());
next.push_str(&updated[..cut.start]);
next.push_str(&updated[cut.end..]);
updated = next;
}
let desired = desired_after_removal(original, entries);
verify_update(updated, settings_path, &desired).map(Some)
}
fn find_next_our_cut(
content: &str,
settings_path: &Path,
entries: &[(&str, &str)],
) -> io::Result<Option<Range>> {
let root = parse_root_object(content, settings_path)?;
let Some(hooks) = root.get_object("hooks") else {
return Ok(None);
};
for (event, command) in entries {
let Some(event_array) = hooks.get_array(event) else {
continue;
};
for (group_index, group) in event_array.elements.iter().enumerate() {
let AstValue::Object(group_object) = group else {
continue;
};
let Some(group_hooks) = group_object.get_array("hooks") else {
continue;
};
let Some(first_our_entry) = group_hooks
.elements
.iter()
.position(|entry| entry_is_our_command(entry, command))
else {
continue;
};
if group_hooks
.elements
.iter()
.all(|entry| entry_is_our_command(entry, command))
{
let group_range = group.range();
let cut = member_cut(
group_index
.checked_sub(1)
.and_then(|prev| event_array.elements.get(prev))
.map(Ranged::range)
.map(|range| range.end),
group_range.start,
group_range.end,
event_array
.elements
.get(group_index + 1)
.map(Ranged::range)
.map(|range| range.start),
);
return Ok(Some(cut));
}
let entry_range = group_hooks.elements[first_our_entry].range();
let cut = member_cut(
first_our_entry
.checked_sub(1)
.and_then(|prev| group_hooks.elements.get(prev))
.map(Ranged::range)
.map(|range| range.end),
entry_range.start,
entry_range.end,
group_hooks
.elements
.get(first_our_entry + 1)
.map(Ranged::range)
.map(|range| range.start),
);
return Ok(Some(cut));
}
}
Ok(None)
}
fn find_next_empty_container_cut(
content: &str,
settings_path: &Path,
events: &[&str],
) -> io::Result<Option<Range>> {
let root = parse_root_object(content, settings_path)?;
let Some(hooks) = root.get_object("hooks") else {
return Ok(None);
};
if hooks.properties.is_empty() {
return Ok(Some(property_cut(&root.properties, "hooks")));
}
for event in events {
if let Some(event_array) = hooks.get_array(event)
&& event_array.elements.is_empty()
{
return Ok(Some(property_cut(&hooks.properties, event)));
}
}
Ok(None)
}
fn property_cut(properties: &[jsonc_parser::ast::ObjectProp<'_>], name: &str) -> Range {
let Some(index) = properties
.iter()
.position(|property| prop_name_text(&property.name) == Some(name))
else {
return Range { start: 0, end: 0 };
};
let property = &properties[index];
member_cut(
index
.checked_sub(1)
.map(|prev| &properties[prev])
.map(|prev| prev.range.end),
property.range.start,
property.range.end,
properties.get(index + 1).map(|next| next.range.start),
)
}
fn member_cut(
prev_end: Option<usize>,
start: usize,
end: usize,
next_start: Option<usize>,
) -> Range {
match (prev_end, next_start) {
(Some(prev), _) => Range { start: prev, end },
(None, Some(next)) => Range { start, end: next },
(None, None) => Range { start, end },
}
}
fn entry_is_our_command(entry: &AstValue, command: &str) -> bool {
let AstValue::Object(entry_object) = entry else {
return false;
};
entry_object.properties.iter().any(|property| {
prop_name_text(&property.name) == Some("command")
&& string_value(&property.value) == Some(command)
})
}
fn desired_after_removal(mut value: JsonValue, entries: &[(&str, &str)]) -> JsonValue {
let Some(root) = value.as_object_mut() else {
return value;
};
let Some(hooks) = root.get_mut("hooks").and_then(JsonValue::as_object_mut) else {
return value;
};
for (event, command) in entries {
let Some(event_array) = hooks.get_mut(*event).and_then(JsonValue::as_array_mut) else {
continue;
};
let had_groups = !event_array.is_empty();
let mut kept_groups = Vec::with_capacity(event_array.len());
for group in event_array.drain(..) {
let mut group = group;
let pruned_empty = match group.get_mut("hooks").and_then(JsonValue::as_array_mut) {
Some(entries) => {
entries.retain(|entry| {
entry.get("command").and_then(JsonValue::as_str) != Some(*command)
});
entries.is_empty()
}
None => false,
};
if !pruned_empty {
kept_groups.push(group);
}
}
*event_array = kept_groups;
if had_groups && event_array.is_empty() {
hooks.remove(*event);
}
}
if hooks.is_empty() {
root.remove("hooks");
}
value
}
fn verify_update(updated: String, settings_path: &Path, desired: &JsonValue) -> io::Result<String> {
let actual = parse_serde(&updated, settings_path)?;
if &actual != desired {
return Err(io::Error::other(format!(
"failed to safely update config at {}",
settings_path.display()
)));
}
Ok(updated)
}
fn append_object_property(
content: &str,
object: &AstObject<'_>,
name: &str,
value: &str,
) -> String {
let key = serde_json::to_string(name).expect("JSON object keys are serializable");
let key_value_separator = object
.properties
.first()
.map(|property| &content[property.name.range().end..property.value.range().start])
.unwrap_or(":");
let insertion = format!("{key}{key_value_separator}{value}");
let delimiter = object_delimiter(content, object);
append_to_container(
content,
object.range,
!object.properties.is_empty(),
delimiter,
&insertion,
)
}
fn append_array_element(content: &str, array: &AstArray<'_>, value: &str) -> String {
let delimiter = array_delimiter(content, array);
append_to_container(
content,
array.range,
!array.elements.is_empty(),
delimiter,
value,
)
}
fn object_delimiter<'a>(content: &'a str, object: &AstObject<'_>) -> &'a str {
match object.properties.as_slice() {
[first, second, ..] => delimiter_suffix(&content[first.range.end..second.range.start]),
[first] => &content[object.range.start + 1..first.range.start],
[] => "",
}
}
fn array_delimiter<'a>(content: &'a str, array: &AstArray<'_>) -> &'a str {
match array.elements.as_slice() {
[first, second, ..] => delimiter_suffix(&content[first.range().end..second.range().start]),
[first] => &content[array.range.start + 1..first.range().start],
[] => "",
}
}
fn delimiter_suffix(delimiter: &str) -> &str {
delimiter
.split_once(',')
.map(|(_, suffix)| suffix)
.unwrap_or(delimiter)
}
fn append_to_container(
content: &str,
range: Range,
has_elements: bool,
delimiter: &str,
value: &str,
) -> String {
let closing = range.end - 1;
let insertion_index = if has_elements {
content[..closing]
.trim_end_matches([' ', '\t', '\n', '\r'])
.len()
} else {
closing
};
let mut updated = String::with_capacity(content.len() + delimiter.len() + value.len() + 1);
updated.push_str(&content[..insertion_index]);
if has_elements {
updated.push(',');
updated.push_str(delimiter);
}
updated.push_str(value);
updated.push_str(&content[insertion_index..]);
updated
}