diff --git a/Cargo.lock b/Cargo.lock index 42d3debeac..50e7a2ccc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 3b1af85496..7e41dfea04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/integration/claude_settings.rs b/src/integration/claude_settings.rs new file mode 100644 index 0000000000..5bc61d754d --- /dev/null +++ b/src/integration/claude_settings.rs @@ -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 { + 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 { + 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, + hook_path: &Path, + canonical: Option<&Value>, +) -> io::Result { + 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, + event: &str, + commands: &[String], + canonical: Option<&Value>, +) -> io::Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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::(&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()); + } + } +} diff --git a/src/integration/mod.rs b/src/integration/mod.rs index b691dce95c..7875b64a3a 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -1,4 +1,5 @@ mod actions; +mod claude_settings; mod command; mod config_edit; mod env; diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 87000eae1d..81939a3bc9 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -4,6 +4,9 @@ use std::path::{Path, PathBuf}; use serde_json::{json, 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, @@ -122,43 +125,17 @@ pub(crate) fn install_claude() -> io::Result { make_executable(&hook_path)?; let settings_path = dir.join("settings.json"); - let mut settings = if settings_path.is_file() { - serde_json::from_str::(&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, @@ -562,43 +539,12 @@ pub(crate) fn uninstall_claude() -> io::Result { let mut updated_settings = false; if settings_path.is_file() { - let mut settings = serde_json::from_str::(&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)?; } }