aboutsummaryrefslogtreecommitdiff
path: root/src/integration
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-08-02 16:03:07 +0300
committerGitHub <noreply@github.com>2026-08-02 16:03:07 +0300
commitd742e51530a78b77e1debe23a72ea7178810f3fd (patch)
treeb1c36a16703acafe1c2a81b7fb1110a14ff641c7 /src/integration
parent9a4ce5e13c1d4622ca63c2947d0eaa018ec35715 (diff)
fix: preserve claude settings formatting (#2089)
refs #2066 Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src/integration')
-rw-r--r--src/integration/claude_settings.rs804
-rw-r--r--src/integration/mod.rs1
-rw-r--r--src/integration/targets.rs84
3 files changed, 820 insertions, 69 deletions
diff --git a/src/integration/claude_settings.rs b/src/integration/claude_settings.rs
new file mode 100644
index 00000000..5bc61d75
--- /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<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());
+ }
+ }
+}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 045d1bce..9fc1886e 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 575c7e46..b387b960 100644
--- a/src/integration/targets.rs
+++ b/src/integration/targets.rs
@@ -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)?;
}
}