From b25937e88c884d6bc9f87e8831d5263e412db4d9 Mon Sep 17 00:00:00 2001 From: data-engineer Date: Fri, 29 May 2026 08:33:38 -0700 Subject: [PATCH 1/2] test: cover Claude Code install lifecycle hooks --- Gradata/src/gradata/hooks/adapters/_base.py | 28 ++ .../src/gradata/hooks/adapters/claude_code.py | 131 +++++++-- .../install_claude_code_settings.json | 62 ++++ .../test_install_claude_code_snapshot.py | 276 ++++++++++++++++++ 4 files changed, 466 insertions(+), 31 deletions(-) create mode 100644 Gradata/tests/snapshots/install_claude_code_settings.json create mode 100644 Gradata/tests/test_install_claude_code_snapshot.py diff --git a/Gradata/src/gradata/hooks/adapters/_base.py b/Gradata/src/gradata/hooks/adapters/_base.py index 7ea67bc5..b66ad4b7 100644 --- a/Gradata/src/gradata/hooks/adapters/_base.py +++ b/Gradata/src/gradata/hooks/adapters/_base.py @@ -136,6 +136,34 @@ def hook_command(brain_dir: Path) -> str: ) +def auto_correct_command(brain_dir: Path) -> str: + return ( + f"BRAIN_DIR={shlex.quote(str(brain_dir))} " + f"{shlex.quote(sys.executable)} -m gradata.hooks.auto_correct" + ) + + +def session_close_command(brain_dir: Path) -> str: + return ( + f"BRAIN_DIR={shlex.quote(str(brain_dir))} " + f"{shlex.quote(sys.executable)} -m gradata.hooks.session_close" + ) + + +def pre_compact_command(brain_dir: Path) -> str: + return ( + f"BRAIN_DIR={shlex.quote(str(brain_dir))} " + f"{shlex.quote(sys.executable)} -m gradata.hooks.pre_compact" + ) + + +def context_inject_command(brain_dir: Path) -> str: + return ( + f"BRAIN_DIR={shlex.quote(str(brain_dir))} " + f"{shlex.quote(sys.executable)} -m gradata.hooks.context_inject" + ) + + def mcp_command(brain_dir: Path) -> list[str]: return [sys.executable, "-m", "gradata.mcp_server", "--brain-dir", str(brain_dir)] diff --git a/Gradata/src/gradata/hooks/adapters/claude_code.py b/Gradata/src/gradata/hooks/adapters/claude_code.py index 04e005a2..3c9c1a27 100644 --- a/Gradata/src/gradata/hooks/adapters/claude_code.py +++ b/Gradata/src/gradata/hooks/adapters/claude_code.py @@ -7,11 +7,15 @@ WRITE_TOOL_ALIASES, InstallResult, _normalize_tool_name, + auto_correct_command, + context_inject_command, extract_from_edit_args, extract_from_write_args, failure, hook_command, hook_signature, + pre_compact_command, + session_close_command, read_json, write_json, ) @@ -58,24 +62,84 @@ def install(brain_dir: Path, agent_config_path: Path) -> InstallResult: data = read_json(agent_config_path) hooks = data.setdefault("hooks", {}) pre_tool = hooks.setdefault("PreToolUse", []) - if any(sig in str(item) for item in pre_tool): + post_tool = hooks.setdefault("PostToolUse", []) + stop = hooks.setdefault("Stop", []) + pre_compact = hooks.setdefault("PreCompact", []) + user_prompt = hooks.setdefault("UserPromptSubmit", []) + has_pre_tool = any(sig in str(item) for item in pre_tool) + has_post_tool = any(sig in str(item) for item in post_tool) + has_stop = any(sig in str(item) for item in stop) + has_pre_compact = any(sig in str(item) for item in pre_compact) + has_user_prompt = any(sig in str(item) for item in user_prompt) + if has_pre_tool and has_post_tool and has_stop and has_pre_compact and has_user_prompt: return InstallResult( AGENT, agent_config_path, "already_present", "hook already present" ) - pre_tool.append( - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": hook_command(brain_dir), - "id": sig, - } - ], - } - ) + if not has_pre_tool: + pre_tool.append( + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": hook_command(brain_dir), + "id": sig, + } + ], + } + ) + if not has_post_tool: + post_tool.append( + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": auto_correct_command(brain_dir), + "id": sig, + } + ], + } + ) + if not has_stop: + stop.append( + { + "hooks": [ + { + "type": "command", + "command": session_close_command(brain_dir), + "id": sig, + } + ], + } + ) + if not has_pre_compact: + pre_compact.append( + { + "matcher": "manual|auto", + "hooks": [ + { + "type": "command", + "command": pre_compact_command(brain_dir), + "id": sig, + } + ], + } + ) + if not has_user_prompt: + user_prompt.append( + { + "hooks": [ + { + "type": "command", + "command": context_inject_command(brain_dir), + "id": sig, + } + ], + } + ) write_json(agent_config_path, data) - return InstallResult(AGENT, agent_config_path, "added", "installed PreToolUse hook") + return InstallResult(AGENT, agent_config_path, "added", "installed Claude Code hooks") except Exception as exc: return failure(AGENT, agent_config_path, exc) @@ -98,27 +162,32 @@ def uninstall(brain_dir: Path, agent_config_path: Path) -> InstallResult: hooks = data.get("hooks") if not isinstance(hooks, dict): return InstallResult(AGENT, agent_config_path, "already_present", "no hooks block") - pre_tool = hooks.get("PreToolUse") - if not isinstance(pre_tool, list): - return InstallResult(AGENT, agent_config_path, "already_present", "no PreToolUse") - removed = 0 - kept: list = [] - for entry in pre_tool: - entry_str = str(entry) - if sig in entry_str: - # Either the entry's `hooks[].id` carries our sig, or the - # whole entry was ours. Drop it. - removed += 1 + for lifecycle in ( + "PreToolUse", + "PostToolUse", + "Stop", + "PreCompact", + "UserPromptSubmit", + ): + entries = hooks.get(lifecycle) + if not isinstance(entries, list): continue - kept.append(entry) + kept: list = [] + for entry in entries: + entry_str = str(entry) + if sig in entry_str: + # Either the entry's `hooks[].id` carries our sig, or the + # whole entry was ours. Drop it. + removed += 1 + continue + kept.append(entry) + if kept: + hooks[lifecycle] = kept + else: + hooks.pop(lifecycle, None) if removed == 0: return InstallResult(AGENT, agent_config_path, "already_present", "hook not present") - - if kept: - hooks["PreToolUse"] = kept - else: - hooks.pop("PreToolUse", None) if not hooks: data.pop("hooks", None) write_json(agent_config_path, data) diff --git a/Gradata/tests/snapshots/install_claude_code_settings.json b/Gradata/tests/snapshots/install_claude_code_settings.json new file mode 100644 index 00000000..eb81b50d --- /dev/null +++ b/Gradata/tests/snapshots/install_claude_code_settings.json @@ -0,0 +1,62 @@ +{ + "hooks": { + "PostToolUse": [ + { + "hooks": [ + { + "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.auto_correct", + "id": "gradata:claude-code:__BRAIN_DIR__", + "type": "command" + } + ], + "matcher": "Edit|Write" + } + ], + "PreCompact": [ + { + "hooks": [ + { + "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.pre_compact", + "id": "gradata:claude-code:__BRAIN_DIR__", + "type": "command" + } + ], + "matcher": "manual|auto" + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.inject_brain_rules", + "id": "gradata:claude-code:__BRAIN_DIR__", + "type": "command" + } + ], + "matcher": "*" + } + ], + "Stop": [ + { + "hooks": [ + { + "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.session_close", + "id": "gradata:claude-code:__BRAIN_DIR__", + "type": "command" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.context_inject", + "id": "gradata:claude-code:__BRAIN_DIR__", + "type": "command" + } + ] + } + ] + } +} diff --git a/Gradata/tests/test_install_claude_code_snapshot.py b/Gradata/tests/test_install_claude_code_snapshot.py new file mode 100644 index 00000000..0a7082ad --- /dev/null +++ b/Gradata/tests/test_install_claude_code_snapshot.py @@ -0,0 +1,276 @@ +"""Snapshot test: gradata install --agent claude-code produces CC settings +with the expected lifecycle hook coverage. + +GRA-1211 / EPIC GRA-1198 / GH #206 + +This test pins the exact .claude/settings.json content produced by +``gradata install --agent claude-code`` so we don't regress hook coverage +as we fold slash commands and lifecycle hooks into the SDK. + +Current state (GRA-1211): all Claude Code lifecycles used by Gradata are wired. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from gradata.hooks.adapters._base import InstallResult, get_adapter + + +def _install_agent(agent: str, brain_dir: Path, config_path: Path) -> InstallResult: + """Call the adapter's install() directly — same as ``gradata install --agent``.""" + adapter = get_adapter(agent) + return adapter.install(brain_dir, config_path) + + +# ── Lifecycle → hook module + adapter wiring status ────────────────────── +ALL_HOOK_LIFECYCLES: dict[str, tuple[str, str]] = { + "PreToolUse": ("gradata.hooks.inject_brain_rules", "wired"), + "PostToolUse": ("gradata.hooks.auto_correct", "wired"), + "Stop": ("gradata.hooks.session_close", "wired"), + "PreCompact": ("gradata.hooks.pre_compact", "wired"), + "UserPromptSubmit": ("gradata.hooks.context_inject", "wired"), +} + + +def _assert_wired_lifecycles(hooks: dict) -> None: + """Assert every lifecycle that should currently be wired IS present.""" + for lifecycle, (_module, status) in ALL_HOOK_LIFECYCLES.items(): + if status == "wired": + assert lifecycle in hooks, ( + f"{lifecycle} hook should be wired but is missing from settings.json.\n" + f"Got hook event keys: {sorted(hooks.keys())}\n" + "Check that the adapter install() method writes this lifecycle." + ) + + +def _assert_no_stray_keys(hooks: dict) -> None: + """Assert no unexpected hook event keys exist in the output.""" + wired_keys = {k for k, (_, s) in ALL_HOOK_LIFECYCLES.items() if s == "wired"} + actual_keys = set(hooks.keys()) + unexpected = actual_keys - wired_keys + assert not unexpected, ( + f"Unexpected hook event keys in snapshot: {sorted(unexpected)}\n" + f"Expected only: {sorted(wired_keys)}.\n" + "If a new lifecycle was intentionally wired, update ALL_HOOK_LIFECYCLES above." + ) + + +def _assert_lifecycle_commands_reference_expected_modules(hooks: dict) -> None: + """Verify every wired lifecycle command points at the expected hook module.""" + for lifecycle, (module, status) in ALL_HOOK_LIFECYCLES.items(): + if status != "wired": + continue + entries = hooks.get(lifecycle, []) + assert isinstance(entries, list) and entries, ( + f"{lifecycle} should have at least 1 hook entry, got {entries}" + ) + commands = [ + hook.get("command", "") + for entry in entries + for hook in entry.get("hooks", []) + ] + matching = [cmd for cmd in commands if module in cmd] + assert matching, ( + f"{lifecycle} should contain {module} hook.\n" + f"Entries: {json.dumps(entries, indent=2)}" + ) + for cmd in matching: + assert "BRAIN_DIR=" in cmd, f"BRAIN_DIR not set in hook command: {cmd}" + + +def _snapshot_path(test_file: str) -> Path: + return Path(test_file).parent / "snapshots" / "install_claude_code_settings.json" + + +def _normalized_snapshot(settings: dict) -> str: + """Return normalized settings.json snapshot text. + + Brain-directory paths are normalized to a stable ``__BRAIN_DIR__`` + placeholder so the snapshot file doesn't change on every test run + (tmp_paths are random per pytest invocation). + """ + import re + + serialized = json.dumps(settings, indent=2, sort_keys=True) + # Normalize: BRAIN_DIR=/tmp/pytest-N/.../brain → BRAIN_DIR=__BRAIN_DIR__ + serialized = re.sub(r"BRAIN_DIR=/tmp/[^ ]+/brain", "BRAIN_DIR=__BRAIN_DIR__", serialized) + # Normalize: hook signature ID + serialized = re.sub( + r'"gradata:claude-code:/tmp/[^"]+brain"', + '"gradata:claude-code:__BRAIN_DIR__"', + serialized, + ) + return serialized + "\n" + + +def _assert_matches_snapshot(settings: dict, test_file: str) -> None: + snapshot_file = _snapshot_path(test_file) + expected = snapshot_file.read_text(encoding="utf-8") + actual = _normalized_snapshot(settings) + assert actual == expected, ( + f"Claude Code settings snapshot mismatch: {snapshot_file}\n" + "Regenerate the snapshot intentionally if adapter output changed." + ) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Tests +# ═══════════════════════════════════════════════════════════════════════════ + + +def test_install_claude_code_produces_correct_settings(tmp_path: Path) -> None: + """Run install_agent and assert all expected lifecycles are present.""" + brain = tmp_path / "brain" + brain.mkdir() + + config_path = tmp_path / ".claude" / "settings.json" + config_path.parent.mkdir(parents=True) + config_path.write_text("{}", encoding="utf-8") + + # Install via adapter API (same as `gradata install --agent claude-code`) + result = _install_agent("claude-code", brain, config_path) + + assert result.action in ("added", "already_present"), ( + f"Install failed: action={result.action}, message={result.message}" + ) + assert config_path.exists(), "settings.json should exist after install" + + settings = json.loads(config_path.read_text(encoding="utf-8")) + hooks = settings.get("hooks", {}) + + # ── Assertions ─────────────────────────────────────────────────────── + _assert_wired_lifecycles(hooks) + _assert_lifecycle_commands_reference_expected_modules(hooks) + _assert_no_stray_keys(hooks) + _assert_matches_snapshot(settings, __file__) + + +def test_install_claude_code_is_idempotent(tmp_path: Path) -> None: + """Re-running install must not create duplicate hook entries.""" + brain = tmp_path / "brain" + brain.mkdir() + + config_path = tmp_path / ".claude" / "settings.json" + config_path.parent.mkdir(parents=True) + config_path.write_text("{}", encoding="utf-8") + + # First install + r1 = _install_agent("claude-code", brain, config_path) + assert r1.action in ("added", "already_present"), r1.message + s1 = json.loads(config_path.read_text(encoding="utf-8")) + + # Second install — must not add duplicates + r2 = _install_agent("claude-code", brain, config_path) + assert r2.action == "already_present", ( + f"Second install should report already_present, got {r2.action}: {r2.message}" + ) + s2 = json.loads(config_path.read_text(encoding="utf-8")) + + assert s1 == s2, ( + f"Second install changed settings content.\n" + f"Before: {json.dumps(s1, indent=2, sort_keys=True)}\n" + f"After: {json.dumps(s2, indent=2, sort_keys=True)}" + ) + + +def test_install_claude_code_respects_existing_settings(tmp_path: Path) -> None: + """Install should not destroy user-owned hooks in settings.json.""" + brain = tmp_path / "brain" + brain.mkdir() + + config_path = tmp_path / ".claude" / "settings.json" + config_path.parent.mkdir(parents=True) + + # Pre-populate with user-owned hooks + pre_existing = { + "hooks": { + "PreToolUse": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "echo 'user hook'", + } + ], + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "echo 'user stop hook'", + } + ], + } + ], + } + } + config_path.write_text(json.dumps(pre_existing, indent=2), encoding="utf-8") + + result = _install_agent("claude-code", brain, config_path) + assert result.action in ("added", "already_present"), result.message + + settings = json.loads(config_path.read_text(encoding="utf-8")) + hooks = settings.get("hooks", {}) + + # User's Stop hook preserved + stop_entries = hooks.get("Stop", []) + user_stop_found = any( + "user stop hook" in hook.get("command", "") + for entry in stop_entries + for hook in entry.get("hooks", []) + ) + assert user_stop_found, f"User's Stop hook was destroyed. Stop entries: {stop_entries}" + + # Gradata hook added alongside user hooks + pre_tool = hooks.get("PreToolUse", []) + gradata_found = any( + "gradata.hooks.inject_brain_rules" in hook.get("command", "") + for entry in pre_tool + for hook in entry.get("hooks", []) + ) + assert gradata_found, f"Gradata hook not installed. PreToolUse: {pre_tool}" + + user_hook_found = any( + "user hook" in hook.get("command", "") + for entry in pre_tool + for hook in entry.get("hooks", []) + ) + assert user_hook_found, f"User's PreToolUse hook was destroyed. PreToolUse: {pre_tool}" + + +def test_install_claude_code_all_lifecycles_documented(tmp_path: Path) -> None: + """All lifecycles in the GRA-1211 acceptance criteria must be wired.""" + brain = tmp_path / "brain" + brain.mkdir() + + config_path = tmp_path / ".claude" / "settings.json" + config_path.parent.mkdir(parents=True) + config_path.write_text("{}", encoding="utf-8") + + _install_agent("claude-code", brain, config_path) + settings = json.loads(config_path.read_text(encoding="utf-8")) + hooks = settings.get("hooks", {}) + + # Report which lifecycles are missing (informational, not assertion failure) + missing = [ + lifecycle + for lifecycle, (_module, status) in ALL_HOOK_LIFECYCLES.items() + if status == "wired" and lifecycle not in hooks + ] + pending = [ + lifecycle + for lifecycle, (_module, status) in ALL_HOOK_LIFECYCLES.items() + if status == "pending" + ] + + assert not missing, ( + f"Lifecycles wired but missing from output: {missing}\n" + f"Check adapter install()." + ) + + assert not pending, f"All GRA-1211 lifecycles should be wired, still pending: {pending}" From f0c70a65c0d9a8b0e305f4542e2b6debec055640 Mon Sep 17 00:00:00 2001 From: data-engineer Date: Fri, 29 May 2026 15:30:51 -0700 Subject: [PATCH 2/2] fix: stabilize Claude Code install snapshot --- .../src/gradata/hooks/adapters/claude_code.py | 2 +- .../snapshots/install_claude_code_settings.json | 12 ++++++------ .../tests/test_install_claude_code_snapshot.py | 16 +++++++++++++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/Gradata/src/gradata/hooks/adapters/claude_code.py b/Gradata/src/gradata/hooks/adapters/claude_code.py index 3c9c1a27..7bddd52e 100644 --- a/Gradata/src/gradata/hooks/adapters/claude_code.py +++ b/Gradata/src/gradata/hooks/adapters/claude_code.py @@ -91,7 +91,7 @@ def install(brain_dir: Path, agent_config_path: Path) -> InstallResult: if not has_post_tool: post_tool.append( { - "matcher": "Edit|Write", + "matcher": "Edit|Write|MultiEdit", "hooks": [ { "type": "command", diff --git a/Gradata/tests/snapshots/install_claude_code_settings.json b/Gradata/tests/snapshots/install_claude_code_settings.json index eb81b50d..d401de3d 100644 --- a/Gradata/tests/snapshots/install_claude_code_settings.json +++ b/Gradata/tests/snapshots/install_claude_code_settings.json @@ -4,19 +4,19 @@ { "hooks": [ { - "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.auto_correct", + "command": "BRAIN_DIR=__BRAIN_DIR__ __PYTHON_EXECUTABLE__ -m gradata.hooks.auto_correct", "id": "gradata:claude-code:__BRAIN_DIR__", "type": "command" } ], - "matcher": "Edit|Write" + "matcher": "Edit|Write|MultiEdit" } ], "PreCompact": [ { "hooks": [ { - "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.pre_compact", + "command": "BRAIN_DIR=__BRAIN_DIR__ __PYTHON_EXECUTABLE__ -m gradata.hooks.pre_compact", "id": "gradata:claude-code:__BRAIN_DIR__", "type": "command" } @@ -28,7 +28,7 @@ { "hooks": [ { - "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.inject_brain_rules", + "command": "BRAIN_DIR=__BRAIN_DIR__ __PYTHON_EXECUTABLE__ -m gradata.hooks.inject_brain_rules", "id": "gradata:claude-code:__BRAIN_DIR__", "type": "command" } @@ -40,7 +40,7 @@ { "hooks": [ { - "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.session_close", + "command": "BRAIN_DIR=__BRAIN_DIR__ __PYTHON_EXECUTABLE__ -m gradata.hooks.session_close", "id": "gradata:claude-code:__BRAIN_DIR__", "type": "command" } @@ -51,7 +51,7 @@ { "hooks": [ { - "command": "BRAIN_DIR=__BRAIN_DIR__ /usr/bin/python3 -m gradata.hooks.context_inject", + "command": "BRAIN_DIR=__BRAIN_DIR__ __PYTHON_EXECUTABLE__ -m gradata.hooks.context_inject", "id": "gradata:claude-code:__BRAIN_DIR__", "type": "command" } diff --git a/Gradata/tests/test_install_claude_code_snapshot.py b/Gradata/tests/test_install_claude_code_snapshot.py index 0a7082ad..64c23c01 100644 --- a/Gradata/tests/test_install_claude_code_snapshot.py +++ b/Gradata/tests/test_install_claude_code_snapshot.py @@ -94,14 +94,24 @@ def _normalized_snapshot(settings: dict) -> str: import re serialized = json.dumps(settings, indent=2, sort_keys=True) - # Normalize: BRAIN_DIR=/tmp/pytest-N/.../brain → BRAIN_DIR=__BRAIN_DIR__ - serialized = re.sub(r"BRAIN_DIR=/tmp/[^ ]+/brain", "BRAIN_DIR=__BRAIN_DIR__", serialized) + # Normalize: BRAIN_DIR=/brain → BRAIN_DIR=__BRAIN_DIR__ + serialized = re.sub( + r"BRAIN_DIR=(?:/|[A-Za-z]:\\\\)[^\"]*?/brain", + "BRAIN_DIR=__BRAIN_DIR__", + serialized, + ) # Normalize: hook signature ID serialized = re.sub( - r'"gradata:claude-code:/tmp/[^"]+brain"', + r'"gradata:claude-code:(?:/|[A-Za-z]:\\\\)[^"]+brain"', '"gradata:claude-code:__BRAIN_DIR__"', serialized, ) + # Normalize: interpreter path in command strings. + serialized = re.sub( + r"(BRAIN_DIR=__BRAIN_DIR__ )\S*(?:python|python(?:\d+(?:\.\d+)?))( -m gradata\.hooks\.)", + r"\1__PYTHON_EXECUTABLE__\2", + serialized, + ) return serialized + "\n"