From e2394ca1d13d7abab731344eb9d836f9bb19c35e Mon Sep 17 00:00:00 2001 From: Julian lechner Date: Thu, 1 Oct 2026 10:23:23 +0200 Subject: [PATCH] fix(install): keep foreign Claude hooks in mixed groups Remove only setup-owned hook entries from a hook group and keep foreign entries with their group options, such as matcher. Co-Authored-By: Claude Opus 5.5 --- scripts/lib/install-options.py | 17 +++++++++++------ scripts/tests/test-install-options.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/scripts/lib/install-options.py b/scripts/lib/install-options.py index a6a757e..3480212 100644 --- a/scripts/lib/install-options.py +++ b/scripts/lib/install-options.py @@ -73,18 +73,23 @@ def merge_json(current, managed): groups = value.get(event, []) old_groups = hooks.get(event, []) if isinstance(groups, list) and isinstance(old_groups, list): - retained = [group for group in old_groups if not any( - re.search(r'(?:flashbang|statusline|record-compact|session-state-pointer)\.(?:ps1|sh)', - str(hook.get('command', '')) + str(hook.get('command_windows', '')), re.I) - for hook in group.get('hooks', []) if isinstance(hook, dict) - )] + retained = [] + for group in old_groups: + if not isinstance(group, dict) or not isinstance(group.get('hooks'), list): + retained.append(group) + continue + foreign = [hook for hook in group['hooks'] if not (isinstance(hook, dict) and re.search( + r'(?:flashbang|statusline|record-compact|session-state-pointer)\.(?:ps1|sh)', + str(hook.get('command', '')) + str(hook.get('command_windows', '')), re.I))] + if foreign: + retained.append(dict(group, hooks=foreign)) merged = groups + [group for group in retained if group not in groups] if merged: hooks[event] = merged else: hooks.pop(event, None) else: - hooks[event] = value + hooks[event] = groups result[key] = hooks elif isinstance(value, dict) and isinstance(result.get(key), dict): result[key] = merge_json(result[key], value) diff --git a/scripts/tests/test-install-options.py b/scripts/tests/test-install-options.py index d16c1fe..6ff1da6 100644 --- a/scripts/tests/test-install-options.py +++ b/scripts/tests/test-install-options.py @@ -45,6 +45,19 @@ class InstallOptionsTests(unittest.TestCase): self.assertEqual(result['mcp_servers'], {'demo': {'command': 'demo', 'env': {'TOKEN_NAME': 'x'}}}) self.assertEqual(result['model_providers'], {'custom': {'name': 'Custom', 'base_url': 'https://example.invalid'}}) + def test_merge_json_removes_only_owned_hooks_from_mixed_group(self): + owned = {'type': 'command', 'command': 'bash "/old/hooks/flashbang.sh"', 'timeout': 8} + foreign = {'type': 'command', 'command': 'notify-done', 'timeout': 3} + current = {'hooks': {'Stop': [{'matcher': 'custom', 'hooks': [owned, foreign]}], + 'PreCompact': [{'hooks': [{'type': 'command', 'command': 'bash "/old/record-compact.sh"'}]}]}} + managed_stop = {'hooks': [{'type': 'command', 'command': 'bash "/new/hooks/flashbang.sh"', 'timeout': 8}]} + managed = {'hooks': {'Stop': [managed_stop], 'PreCompact': []}} + + hooks = OPTIONS.merge_json(current, managed)['hooks'] + + self.assertEqual(hooks['Stop'], [managed_stop, {'matcher': 'custom', 'hooks': [foreign]}]) + self.assertNotIn('PreCompact', hooks) + def test_merge_cli_accepts_bom_input_and_preserves_non_ascii(self): with tempfile.TemporaryDirectory() as directory: current = Path(directory) / 'config.toml'