fix: preserve claude settings formatting (#2089)
refs #2066 Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
This commit is contained in:
parent
9a4ce5e13c
commit
d742e51530
|
|
@ -681,6 +681,7 @@ dependencies = [
|
|||
"crossterm",
|
||||
"ctrlc",
|
||||
"interprocess",
|
||||
"jsonc-parser",
|
||||
"libc",
|
||||
"png",
|
||||
"portable-pty",
|
||||
|
|
@ -790,6 +791,15 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonc-parser"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0560e3f9a9a03ea6b6e90b41138c5db9e21526c99eb192c1a26c68176593285"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kasuari"
|
||||
version = "0.4.12"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ clap_complete = "4.5"
|
|||
crossterm = "0.29"
|
||||
ctrlc = { version = "3", features = ["termination"] }
|
||||
interprocess = "2.4.2"
|
||||
jsonc-parser = { version = "0.33.1", default-features = false, features = ["cst", "serde_json"] }
|
||||
libc = "0.2"
|
||||
portable-pty = "=0.9.0"
|
||||
png = "0.17"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,804 @@
|
|||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use jsonc_parser::ast::{Array as AstArray, Object as AstObject, Value as AstValue};
|
||||
use jsonc_parser::common::Ranged;
|
||||
use jsonc_parser::cst::{CstInputValue, CstNode, CstObject, CstRootNode};
|
||||
use jsonc_parser::{json, parse_to_ast, CollectOptions, ParseOptions};
|
||||
use serde_json::{json as serde_json_value, Map, Value};
|
||||
|
||||
use super::command::hook_command;
|
||||
use super::config_edit::{
|
||||
ensure_command_hook, ensure_hooks_object, hook_command_variants, hooks_object_if_present,
|
||||
is_matching_command_hook,
|
||||
};
|
||||
|
||||
struct HookRemoval {
|
||||
event: &'static str,
|
||||
actions: &'static [&'static str],
|
||||
}
|
||||
|
||||
const HOOK_REMOVALS: &[HookRemoval] = &[
|
||||
HookRemoval {
|
||||
event: "PostToolUse",
|
||||
actions: &["working"],
|
||||
},
|
||||
HookRemoval {
|
||||
event: "PostToolUseFailure",
|
||||
actions: &["working"],
|
||||
},
|
||||
HookRemoval {
|
||||
event: "SubagentStop",
|
||||
actions: &["working"],
|
||||
},
|
||||
HookRemoval {
|
||||
event: "PermissionRequest",
|
||||
actions: &["blocked"],
|
||||
},
|
||||
HookRemoval {
|
||||
event: "SessionStart",
|
||||
actions: &["idle", "session"],
|
||||
},
|
||||
HookRemoval {
|
||||
event: "UserPromptSubmit",
|
||||
actions: &["working"],
|
||||
},
|
||||
HookRemoval {
|
||||
event: "PreToolUse",
|
||||
actions: &["working"],
|
||||
},
|
||||
HookRemoval {
|
||||
event: "Stop",
|
||||
actions: &["idle"],
|
||||
},
|
||||
HookRemoval {
|
||||
event: "SessionEnd",
|
||||
actions: &["release"],
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) fn install(content: &str, settings_path: &Path, hook_path: &Path) -> io::Result<String> {
|
||||
let original = parse_value(content, settings_path)?;
|
||||
let mut desired = original.clone();
|
||||
let hooks = ensure_hooks_object(
|
||||
&mut desired,
|
||||
settings_path,
|
||||
"claude settings",
|
||||
"claude settings hooks",
|
||||
)?;
|
||||
let canonical = canonical_hook_value(hook_path);
|
||||
apply_value_removals(hooks, hook_path, Some(&canonical))?;
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"SessionStart",
|
||||
hook_command(hook_path, Some("session")),
|
||||
10,
|
||||
Some("*"),
|
||||
)?;
|
||||
|
||||
if desired == original {
|
||||
return Ok(content.to_string());
|
||||
}
|
||||
|
||||
rewrite(
|
||||
content,
|
||||
settings_path,
|
||||
hook_path,
|
||||
EditKind::Install,
|
||||
&desired,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn uninstall(
|
||||
content: &str,
|
||||
settings_path: &Path,
|
||||
hook_path: &Path,
|
||||
) -> io::Result<String> {
|
||||
let original = parse_value(content, settings_path)?;
|
||||
let mut desired = original.clone();
|
||||
let mut removed = false;
|
||||
|
||||
if let Some(hooks) = hooks_object_if_present(
|
||||
&mut desired,
|
||||
settings_path,
|
||||
"claude settings",
|
||||
"claude settings hooks",
|
||||
)? {
|
||||
removed = apply_value_removals(hooks, hook_path, None)?;
|
||||
}
|
||||
|
||||
if !removed {
|
||||
return Ok(content.to_string());
|
||||
}
|
||||
|
||||
rewrite(
|
||||
content,
|
||||
settings_path,
|
||||
hook_path,
|
||||
EditKind::Uninstall,
|
||||
&desired,
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_value_removals(
|
||||
hooks: &mut Map<String, Value>,
|
||||
hook_path: &Path,
|
||||
canonical: Option<&Value>,
|
||||
) -> io::Result<bool> {
|
||||
let mut removed = false;
|
||||
for policy in HOOK_REMOVALS {
|
||||
let commands = removal_commands(policy, hook_path);
|
||||
removed |= remove_value_event_commands(
|
||||
hooks,
|
||||
policy.event,
|
||||
&commands,
|
||||
(policy.event == "SessionStart")
|
||||
.then_some(canonical)
|
||||
.flatten(),
|
||||
)?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
fn remove_value_event_commands(
|
||||
hooks: &mut Map<String, Value>,
|
||||
event: &str,
|
||||
commands: &[String],
|
||||
canonical: Option<&Value>,
|
||||
) -> 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;
|
||||
let mut canonical_preserved = false;
|
||||
|
||||
entries.retain_mut(|entry| {
|
||||
if !canonical_preserved && canonical.is_some_and(|canonical| entry == canonical) {
|
||||
canonical_preserved = true;
|
||||
return true;
|
||||
}
|
||||
let Some(command_entries) = entry.get_mut("hooks").and_then(Value::as_array_mut) else {
|
||||
return true;
|
||||
};
|
||||
let before = command_entries.len();
|
||||
command_entries.retain(|entry| {
|
||||
!commands
|
||||
.iter()
|
||||
.any(|command| is_matching_command_hook(entry, command))
|
||||
});
|
||||
removed |= command_entries.len() != before;
|
||||
!command_entries.is_empty()
|
||||
});
|
||||
|
||||
if entries.is_empty() && canonical.is_none() {
|
||||
hooks.remove(event);
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum EditKind {
|
||||
Install,
|
||||
Uninstall,
|
||||
}
|
||||
|
||||
fn rewrite(
|
||||
content: &str,
|
||||
settings_path: &Path,
|
||||
hook_path: &Path,
|
||||
kind: EditKind,
|
||||
desired: &Value,
|
||||
) -> io::Result<String> {
|
||||
let root = CstRootNode::parse(content, &strict_parse_options()).map_err(|err| {
|
||||
io::Error::other(format!(
|
||||
"failed to parse {}: {err}",
|
||||
settings_path.display()
|
||||
))
|
||||
})?;
|
||||
let root_value = root.value().ok_or_else(|| {
|
||||
io::Error::other(format!(
|
||||
"claude settings at {} must be a JSON object",
|
||||
settings_path.display()
|
||||
))
|
||||
})?;
|
||||
reject_duplicate_keys(&root_value, settings_path)?;
|
||||
let root_object = root_value.as_object().ok_or_else(|| {
|
||||
io::Error::other(format!(
|
||||
"claude settings at {} must be a JSON object",
|
||||
settings_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let hooks = match root_object.get("hooks") {
|
||||
Some(property) => property.object_value().ok_or_else(|| {
|
||||
io::Error::other(format!(
|
||||
"claude settings hooks at {} must be a JSON object",
|
||||
settings_path.display()
|
||||
))
|
||||
})?,
|
||||
None if kind == EditKind::Install
|
||||
&& direct_children_are_compact(&root_object.children()) =>
|
||||
{
|
||||
let updated = append_hooks_property_compact(content, hook_path, settings_path)?;
|
||||
return verify_updated(updated, settings_path, desired);
|
||||
}
|
||||
None if kind == EditKind::Install => root_object
|
||||
.append("hooks", CstInputValue::Object(Vec::new()))
|
||||
.object_value()
|
||||
.ok_or_else(|| io::Error::other("failed to create claude settings hooks object"))?,
|
||||
None => return Ok(content.to_string()),
|
||||
};
|
||||
|
||||
let canonical = canonical_hook_value(hook_path);
|
||||
let mut canonical_preserved = false;
|
||||
for policy in HOOK_REMOVALS {
|
||||
let commands = removal_commands(policy, hook_path);
|
||||
canonical_preserved |= remove_event_commands(
|
||||
&hooks,
|
||||
policy.event,
|
||||
&commands,
|
||||
kind == EditKind::Install,
|
||||
&canonical,
|
||||
)?;
|
||||
}
|
||||
|
||||
if kind == EditKind::Install && !canonical_preserved {
|
||||
match hooks.get("SessionStart") {
|
||||
Some(property) => {
|
||||
let session_start = property.array_value().ok_or_else(|| {
|
||||
io::Error::other("hook entries for SessionStart must be an array")
|
||||
})?;
|
||||
if direct_children_are_compact(&session_start.children()) {
|
||||
let updated =
|
||||
append_session_entry_compact(&root.to_string(), hook_path, settings_path)?;
|
||||
return verify_updated(updated, settings_path, desired);
|
||||
}
|
||||
session_start.append(canonical_hook_input(hook_path));
|
||||
}
|
||||
None if direct_children_are_compact(&hooks.children()) => {
|
||||
let updated =
|
||||
append_session_property_compact(&root.to_string(), hook_path, settings_path)?;
|
||||
return verify_updated(updated, settings_path, desired);
|
||||
}
|
||||
None => {
|
||||
let session_start = hooks
|
||||
.append("SessionStart", CstInputValue::Array(Vec::new()))
|
||||
.array_value()
|
||||
.ok_or_else(|| io::Error::other("failed to create SessionStart hook array"))?;
|
||||
session_start.append(canonical_hook_input(hook_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verify_updated(root.to_string(), settings_path, desired)
|
||||
}
|
||||
|
||||
fn remove_event_commands(
|
||||
hooks: &CstObject,
|
||||
event: &str,
|
||||
commands: &[String],
|
||||
installing: bool,
|
||||
canonical: &Value,
|
||||
) -> io::Result<bool> {
|
||||
let Some(event_property) = hooks.get(event) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let entries = event_property
|
||||
.array_value()
|
||||
.ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?;
|
||||
let mut canonical_preserved = false;
|
||||
|
||||
for entry in entries.elements() {
|
||||
if installing
|
||||
&& event == "SessionStart"
|
||||
&& !canonical_preserved
|
||||
&& entry.to_serde_value().as_ref() == Some(canonical)
|
||||
{
|
||||
canonical_preserved = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(entry_object) = entry.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let Some(command_entries) = entry_object
|
||||
.get("hooks")
|
||||
.and_then(|property| property.array_value())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for command_entry in command_entries.elements() {
|
||||
let matches = command_entry.to_serde_value().is_some_and(|value| {
|
||||
commands
|
||||
.iter()
|
||||
.any(|command| is_matching_command_hook(&value, command))
|
||||
});
|
||||
if matches {
|
||||
command_entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if command_entries.elements().is_empty() {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if entries.elements().is_empty() && !(installing && event == "SessionStart") {
|
||||
event_property.remove();
|
||||
}
|
||||
|
||||
Ok(canonical_preserved)
|
||||
}
|
||||
|
||||
fn removal_commands(policy: &HookRemoval, hook_path: &Path) -> Vec<String> {
|
||||
policy
|
||||
.actions
|
||||
.iter()
|
||||
.flat_map(|action| hook_command_variants(hook_path, Some(action)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn canonical_hook_value(hook_path: &Path) -> Value {
|
||||
serde_json_value!({
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": hook_command(hook_path, Some("session")),
|
||||
"timeout": 10,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_hook_input(hook_path: &Path) -> CstInputValue {
|
||||
let command = hook_command(hook_path, Some("session"));
|
||||
json!({
|
||||
matcher: "*",
|
||||
hooks: [{
|
||||
"type": "command",
|
||||
command: command,
|
||||
timeout: 10u64,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn append_hooks_property_compact(
|
||||
content: &str,
|
||||
hook_path: &Path,
|
||||
settings_path: &Path,
|
||||
) -> io::Result<String> {
|
||||
let root = parse_ast_root_object(content, settings_path)?;
|
||||
let value = format!("{{\"SessionStart\":[{}]}}", canonical_hook_json(hook_path)?);
|
||||
Ok(append_object_property(content, &root, "hooks", &value))
|
||||
}
|
||||
|
||||
fn append_session_property_compact(
|
||||
content: &str,
|
||||
hook_path: &Path,
|
||||
settings_path: &Path,
|
||||
) -> io::Result<String> {
|
||||
let root = parse_ast_root_object(content, settings_path)?;
|
||||
let hooks = root.get_object("hooks").ok_or_else(|| {
|
||||
io::Error::other(format!(
|
||||
"claude settings hooks at {} must be a JSON object",
|
||||
settings_path.display()
|
||||
))
|
||||
})?;
|
||||
let value = format!("[{}]", canonical_hook_json(hook_path)?);
|
||||
Ok(append_object_property(
|
||||
content,
|
||||
hooks,
|
||||
"SessionStart",
|
||||
&value,
|
||||
))
|
||||
}
|
||||
|
||||
fn append_session_entry_compact(
|
||||
content: &str,
|
||||
hook_path: &Path,
|
||||
settings_path: &Path,
|
||||
) -> io::Result<String> {
|
||||
let root = parse_ast_root_object(content, settings_path)?;
|
||||
let session_start = root
|
||||
.get_object("hooks")
|
||||
.and_then(|hooks| hooks.get_array("SessionStart"))
|
||||
.ok_or_else(|| io::Error::other("hook entries for SessionStart must be an array"))?;
|
||||
Ok(append_array_element(
|
||||
content,
|
||||
session_start,
|
||||
&canonical_hook_json(hook_path)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_ast_root_object<'a>(content: &'a str, settings_path: &Path) -> io::Result<AstObject<'a>> {
|
||||
let parsed = parse_to_ast(content, &CollectOptions::default(), &strict_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!(
|
||||
"claude settings at {} must be a JSON object",
|
||||
settings_path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
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: jsonc_parser::common::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']).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
|
||||
}
|
||||
|
||||
fn canonical_hook_json(hook_path: &Path) -> io::Result<String> {
|
||||
let command = serde_json::to_string(&hook_command(hook_path, Some("session")))?;
|
||||
Ok(format!(
|
||||
"{{\"matcher\":\"*\",\"hooks\":[{{\"type\":\"command\",\"command\":{command},\"timeout\":10}}]}}"
|
||||
))
|
||||
}
|
||||
|
||||
fn verify_updated(updated: String, settings_path: &Path, desired: &Value) -> io::Result<String> {
|
||||
let actual = parse_value(&updated, settings_path)?;
|
||||
if &actual != desired {
|
||||
return Err(io::Error::other(format!(
|
||||
"failed to safely update claude settings at {}",
|
||||
settings_path.display()
|
||||
)));
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
fn direct_children_are_compact(children: &[CstNode]) -> bool {
|
||||
!children.iter().any(CstNode::is_newline)
|
||||
}
|
||||
|
||||
fn parse_value(content: &str, settings_path: &Path) -> io::Result<Value> {
|
||||
serde_json::from_str(content).map_err(|err| {
|
||||
io::Error::other(format!(
|
||||
"failed to parse {}: {err}",
|
||||
settings_path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn reject_duplicate_keys(node: &CstNode, settings_path: &Path) -> io::Result<()> {
|
||||
if let Some(object) = node.as_object() {
|
||||
let mut names = HashSet::new();
|
||||
for property in object.properties() {
|
||||
let name = property
|
||||
.name()
|
||||
.ok_or_else(|| io::Error::other("JSON object property is missing a name"))?
|
||||
.decoded_value()
|
||||
.map_err(|err| io::Error::other(format!("failed to decode JSON key: {err}")))?;
|
||||
if !names.insert(name.clone()) {
|
||||
return Err(io::Error::other(format!(
|
||||
"claude settings at {} contains duplicate key {name:?}",
|
||||
settings_path.display()
|
||||
)));
|
||||
}
|
||||
if let Some(value) = property.value() {
|
||||
reject_duplicate_keys(&value, settings_path)?;
|
||||
}
|
||||
}
|
||||
} else if let Some(array) = node.as_array() {
|
||||
for element in array.elements() {
|
||||
reject_duplicate_keys(&element, settings_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strict_parse_options() -> ParseOptions {
|
||||
ParseOptions {
|
||||
allow_comments: false,
|
||||
allow_loose_object_property_names: false,
|
||||
allow_trailing_commas: false,
|
||||
allow_missing_commas: false,
|
||||
allow_single_quoted_strings: false,
|
||||
allow_hexadecimal_numbers: false,
|
||||
allow_unary_plus_numbers: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn paths() -> (&'static Path, &'static Path) {
|
||||
(
|
||||
Path::new("/home/test/.claude/settings.json"),
|
||||
Path::new("/home/test/.claude/hooks/herdr-agent-state.sh"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_preserves_untouched_formatting_and_complete_trailing_suffix() {
|
||||
let (settings_path, hook_path) = paths();
|
||||
let input = concat!(
|
||||
"{\r\n",
|
||||
" \"zeta\" : {\"escaped\":\"\\u0061\", \"number\":1e+02},\r\n",
|
||||
" \"hooks\" : {\r\n",
|
||||
" \"Notification\" : [{\"matcher\":\"keep\",\"hooks\":[]}]\r\n",
|
||||
" },\r\n",
|
||||
" \"alpha\" : 1\r\n",
|
||||
"}\r\n\r\n",
|
||||
);
|
||||
|
||||
let updated = install(input, settings_path, hook_path).unwrap();
|
||||
|
||||
assert!(updated.starts_with(concat!(
|
||||
"{\r\n",
|
||||
" \"zeta\" : {\"escaped\":\"\\u0061\", \"number\":1e+02},\r\n",
|
||||
" \"hooks\" : {\r\n",
|
||||
" \"Notification\" : [{\"matcher\":\"keep\",\"hooks\":[]}],\r\n",
|
||||
)));
|
||||
assert!(updated.ends_with(concat!(
|
||||
"\r\n },\r\n",
|
||||
" \"alpha\" : 1\r\n",
|
||||
"}\r\n\r\n",
|
||||
)));
|
||||
assert!(!updated.replace("\r\n", "").contains('\n'));
|
||||
assert!(updated.contains("\"SessionStart\""));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&updated).unwrap()["zeta"]["number"],
|
||||
100.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_keeps_compact_containers_compact() {
|
||||
let (settings_path, hook_path) = paths();
|
||||
let canonical = canonical_hook_json(hook_path).unwrap();
|
||||
let cases = [
|
||||
(
|
||||
"{\"zeta\":{\"escaped\":\"\\u0061\",\"n\":1e+02},\"alpha\":1}\r\n",
|
||||
format!(
|
||||
"{{\"zeta\":{{\"escaped\":\"\\u0061\",\"n\":1e+02}},\"alpha\":1,\"hooks\":{{\"SessionStart\":[{canonical}]}}}}\r\n"
|
||||
),
|
||||
),
|
||||
(
|
||||
"{\"hooks\":{\"Notification\":[{\"matcher\":\"keep\",\"hooks\":[]}]}, \"alpha\":1}",
|
||||
format!(
|
||||
"{{\"hooks\":{{\"Notification\":[{{\"matcher\":\"keep\",\"hooks\":[]}}],\"SessionStart\":[{canonical}]}}, \"alpha\":1}}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"{\"hooks\":{\"SessionStart\":[{\"matcher\":\"keep\",\"hooks\":[{\"type\":\"command\",\"command\":\"echo keep\"}]}]}}",
|
||||
format!(
|
||||
"{{\"hooks\":{{\"SessionStart\":[{{\"matcher\":\"keep\",\"hooks\":[{{\"type\":\"command\",\"command\":\"echo keep\"}}]}},{canonical}]}}}}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"{\"zeta\":{\n \"x\":1\n},\"alpha\":1}",
|
||||
format!(
|
||||
"{{\"zeta\":{{\n \"x\":1\n}},\"alpha\":1,\"hooks\":{{\"SessionStart\":[{canonical}]}}}}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"{\"hooks\":{\"Notification\":[\n {\"matcher\":\"keep\",\"hooks\":[]}\n]},\"alpha\":1}",
|
||||
format!(
|
||||
"{{\"hooks\":{{\"Notification\":[\n {{\"matcher\":\"keep\",\"hooks\":[]}}\n],\"SessionStart\":[{canonical}]}},\"alpha\":1}}"
|
||||
),
|
||||
),
|
||||
(
|
||||
"{\"hooks\":{\"SessionStart\":[{\n \"matcher\":\"keep\",\n \"hooks\":[{\"type\":\"command\",\"command\":\"echo keep\"}]\n}]}}",
|
||||
format!(
|
||||
"{{\"hooks\":{{\"SessionStart\":[{{\n \"matcher\":\"keep\",\n \"hooks\":[{{\"type\":\"command\",\"command\":\"echo keep\"}}]\n}},{canonical}]}}}}"
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
for (input, expected) in cases {
|
||||
assert_eq!(install(input, settings_path, hook_path).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_is_a_byte_exact_noop_for_a_canonical_hook() {
|
||||
let (settings_path, hook_path) = paths();
|
||||
let command = serde_json::to_string(&hook_command(hook_path, Some("session"))).unwrap();
|
||||
let input = format!(
|
||||
"{{\"hooks\":{{\"SessionStart\":[{{\"hooks\":[{{\"timeout\":10,\"command\":{command},\"type\":\"command\"}}],\"matcher\":\"*\"}}]}},\"escaped\":\"\\u0061\"}} \r\n\r\n"
|
||||
);
|
||||
|
||||
let updated = install(&input, settings_path, hook_path).unwrap();
|
||||
|
||||
assert_eq!(updated, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_preserves_canonical_session_start_position_during_migration() {
|
||||
let (settings_path, hook_path) = paths();
|
||||
let canonical = canonical_hook_json(hook_path).unwrap();
|
||||
let old_command = serde_json::to_string(&hook_command(hook_path, Some("working"))).unwrap();
|
||||
let session_start = format!(
|
||||
"\"SessionStart\":[{canonical},{{\"matcher\":\"foreign\",\"hooks\":[{{\"type\":\"command\",\"command\":\"echo keep\"}}]}}]"
|
||||
);
|
||||
let old_event = [
|
||||
"\"PostToolUse\":[{\"matcher\":\"*\",\"hooks\":[{\"type\":\"command\",\"command\":",
|
||||
&old_command,
|
||||
"}]}]",
|
||||
]
|
||||
.concat();
|
||||
let input = ["{\"hooks\":{", &session_start, ",", &old_event, "}}"].concat();
|
||||
let expected = ["{\"hooks\":{", &session_start, "}}"].concat();
|
||||
|
||||
let updated = install(&input, settings_path, hook_path).unwrap();
|
||||
|
||||
assert_eq!(updated, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_removes_only_owned_commands_from_shared_hook_groups() {
|
||||
let (settings_path, hook_path) = paths();
|
||||
let old_command = serde_json::to_string(&hook_command(hook_path, Some("working"))).unwrap();
|
||||
let input = format!(
|
||||
concat!(
|
||||
"{{\n",
|
||||
" \"hooks\": {{\n",
|
||||
" \"PostToolUse\": [{{\n",
|
||||
" \"matcher\": \"*\",\n",
|
||||
" \"hooks\": [\n",
|
||||
" {{\"type\":\"command\",\"command\":{old_command},\"timeout\":10}},\n",
|
||||
" {{ \"type\" : \"command\", \"command\" : \"echo keep\", \"timeout\" : 3 }}\n",
|
||||
" ]\n",
|
||||
" }}],\n",
|
||||
" \"Notification\": [{{\"matcher\":\"keep\",\"hooks\":[]}}]\n",
|
||||
" }}\n",
|
||||
"}}\n",
|
||||
),
|
||||
old_command = old_command,
|
||||
);
|
||||
|
||||
let updated = install(&input, settings_path, hook_path).unwrap();
|
||||
|
||||
assert!(!updated.contains(&old_command));
|
||||
assert!(updated.contains(
|
||||
" { \"type\" : \"command\", \"command\" : \"echo keep\", \"timeout\" : 3 }"
|
||||
));
|
||||
assert!(updated.contains(" \"Notification\": [{\"matcher\":\"keep\",\"hooks\":[]}]"));
|
||||
let parsed: Value = serde_json::from_str(&updated).unwrap();
|
||||
assert_eq!(
|
||||
parsed["hooks"]["PostToolUse"][0]["hooks"][0]["command"],
|
||||
"echo keep"
|
||||
);
|
||||
assert_eq!(parsed["hooks"]["SessionStart"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uninstall_preserves_unrelated_hook_text() {
|
||||
let (settings_path, hook_path) = paths();
|
||||
let command = serde_json::to_string(&hook_command(hook_path, Some("session"))).unwrap();
|
||||
let input = format!(
|
||||
concat!(
|
||||
"{{\n",
|
||||
" \"before\" : \"\\u0061\",\n",
|
||||
" \"hooks\" : {{\n",
|
||||
" \"SessionStart\" : [{{\n",
|
||||
" \"matcher\" : \"*\",\n",
|
||||
" \"hooks\" : [\n",
|
||||
" {{\"type\":\"command\",\"command\":{command},\"timeout\":10}},\n",
|
||||
" {{ \"type\" : \"command\", \"command\" : \"echo keep\" }}\n",
|
||||
" ]\n",
|
||||
" }}]\n",
|
||||
" }},\n",
|
||||
" \"after\" : 1e+02\n",
|
||||
"}}\n\n",
|
||||
),
|
||||
command = command,
|
||||
);
|
||||
|
||||
let updated = uninstall(&input, settings_path, hook_path).unwrap();
|
||||
|
||||
assert_ne!(updated, input);
|
||||
assert!(!updated.contains(&command));
|
||||
assert!(updated
|
||||
.contains(" { \"type\" : \"command\", \"command\" : \"echo keep\" }"));
|
||||
assert!(updated.starts_with("{\n \"before\" : \"\\u0061\","));
|
||||
assert!(updated.ends_with(" \"after\" : 1e+02\n}\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_duplicate_keys() {
|
||||
let (settings_path, hook_path) = paths();
|
||||
let error = install(
|
||||
r#"{"alpha": 1, "alpha": 2, "hooks": {}}"#,
|
||||
settings_path,
|
||||
hook_path,
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(error.contains("duplicate key \"alpha\""), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_keeps_structurally_invalid_content_unchanged() {
|
||||
let (settings_path, hook_path) = paths();
|
||||
for input in ["[]", r#"{"hooks": []}"#, r#"{"hooks":{"SessionStart":{}}}"#] {
|
||||
assert!(install(input, settings_path, hook_path).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
mod actions;
|
||||
mod claude_settings;
|
||||
mod command;
|
||||
mod config_edit;
|
||||
mod env;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ use std::path::{Path, PathBuf};
|
|||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use super::claude_settings::{
|
||||
install as install_claude_settings, uninstall as uninstall_claude_settings,
|
||||
};
|
||||
use super::command::{hook_command, shell_single_quote};
|
||||
use super::config_edit::{
|
||||
build_codex_config_with_hooks, build_kimi_config_with_hooks, ensure_command_hook,
|
||||
|
|
@ -123,43 +126,17 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
|
|||
make_executable(&hook_path)?;
|
||||
|
||||
let settings_path = dir.join("settings.json");
|
||||
let mut settings = if settings_path.is_file() {
|
||||
serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| {
|
||||
io::Error::other(format!(
|
||||
"failed to parse {}: {err}",
|
||||
settings_path.display()
|
||||
))
|
||||
})?
|
||||
let existing_settings = if settings_path.is_file() {
|
||||
fs::read_to_string(&settings_path)?
|
||||
} else {
|
||||
json!({})
|
||||
"{}".to_string()
|
||||
};
|
||||
|
||||
let hooks = ensure_hooks_object(
|
||||
&mut settings,
|
||||
&settings_path,
|
||||
"claude settings",
|
||||
"claude settings hooks",
|
||||
)?;
|
||||
remove_hook_commands(hooks, "PostToolUse", &hook_path, Some("working"))?;
|
||||
remove_hook_commands(hooks, "PostToolUseFailure", &hook_path, Some("working"))?;
|
||||
remove_hook_commands(hooks, "SubagentStop", &hook_path, Some("working"))?;
|
||||
remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
|
||||
remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?;
|
||||
remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?;
|
||||
remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?;
|
||||
remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
|
||||
remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?;
|
||||
remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?;
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"SessionStart",
|
||||
hook_command(&hook_path, Some("session")),
|
||||
10,
|
||||
Some("*"),
|
||||
)?;
|
||||
let updated_settings = install_claude_settings(&existing_settings, &settings_path, &hook_path)?;
|
||||
remove_legacy_bash_hook_file(&hook_path)?;
|
||||
|
||||
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
|
||||
if updated_settings != existing_settings {
|
||||
fs::write(&settings_path, updated_settings)?;
|
||||
}
|
||||
|
||||
Ok(ClaudeInstallPaths {
|
||||
hook_path,
|
||||
|
|
@ -563,43 +540,12 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
|
|||
let mut updated_settings = false;
|
||||
|
||||
if settings_path.is_file() {
|
||||
let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?)
|
||||
.map_err(|err| {
|
||||
io::Error::other(format!(
|
||||
"failed to parse {}: {err}",
|
||||
settings_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(hooks) = hooks_object_if_present(
|
||||
&mut settings,
|
||||
&settings_path,
|
||||
"claude settings",
|
||||
"claude settings hooks",
|
||||
)? {
|
||||
updated_settings |=
|
||||
remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?;
|
||||
updated_settings |=
|
||||
remove_hook_commands(hooks, "SessionStart", &hook_path, Some("session"))?;
|
||||
updated_settings |=
|
||||
remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?;
|
||||
updated_settings |=
|
||||
remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?;
|
||||
updated_settings |=
|
||||
remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?;
|
||||
updated_settings |=
|
||||
remove_hook_commands(hooks, "PostToolUse", &hook_path, Some("working"))?;
|
||||
updated_settings |=
|
||||
remove_hook_commands(hooks, "PostToolUseFailure", &hook_path, Some("working"))?;
|
||||
updated_settings |=
|
||||
remove_hook_commands(hooks, "SubagentStop", &hook_path, Some("working"))?;
|
||||
updated_settings |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?;
|
||||
updated_settings |=
|
||||
remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?;
|
||||
}
|
||||
|
||||
let existing_settings = fs::read_to_string(&settings_path)?;
|
||||
let new_settings =
|
||||
uninstall_claude_settings(&existing_settings, &settings_path, &hook_path)?;
|
||||
updated_settings = new_settings != existing_settings;
|
||||
if updated_settings {
|
||||
fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
|
||||
fs::write(&settings_path, new_settings)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue