From afaecc6e79bcdca4f9412239c69dbec5a9d4ba8d Mon Sep 17 00:00:00 2001 From: harshitlarl Date: Sun, 22 Mar 2026 22:04:12 +0530 Subject: [PATCH] feat: show installed hook actions during apm install --- src/apm_cli/commands/install.py | 25 ++- src/apm_cli/integration/hook_integrator.py | 103 +++++++++++- tests/unit/test_install_hook_transparency.py | 164 +++++++++++++++++++ 3 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_install_hook_transparency.py diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py index ed545485..1abf4b99 100644 --- a/src/apm_cli/commands/install.py +++ b/src/apm_cli/commands/install.py @@ -777,6 +777,27 @@ def _log_integration(msg): if logger: logger.tree_item(msg) + def _log_hook_details(hook_result): + if not logger: + return + for payload in hook_result.display_payloads: + source_name = payload.get("source_hook_file", "hook file") + actions = payload.get("actions", []) + if actions: + for action in actions: + logger.tree_item( + f" {action['event']}: {action['summary']} ({source_name})" + ) + else: + logger.tree_item(f" Hook file integrated: {source_name}") + + if logger.verbose: + logger.verbose_detail( + f" Hook JSON ({source_name} -> {payload['output_path']}):" + ) + for line in payload["rendered_json"].splitlines(): + logger.verbose_detail(f" {line}") + # --- prompts --- prompt_result = prompt_integrator.integrate_package_prompts( package_info, project_root, @@ -927,6 +948,7 @@ def _log_integration(msg): if hook_result.hooks_integrated > 0: result["hooks"] += hook_result.hooks_integrated _log_integration(f" └─ {hook_result.hooks_integrated} hook(s) integrated -> .github/hooks/") + _log_hook_details(hook_result) for tp in hook_result.target_paths: deployed.append(tp.relative_to(project_root).as_posix()) if integrate_claude: @@ -938,6 +960,7 @@ def _log_integration(msg): if hook_result_claude.hooks_integrated > 0: result["hooks"] += hook_result_claude.hooks_integrated _log_integration(f" └─ {hook_result_claude.hooks_integrated} hook(s) integrated -> .claude/settings.json") + _log_hook_details(hook_result_claude) for tp in hook_result_claude.target_paths: deployed.append(tp.relative_to(project_root).as_posix()) @@ -950,6 +973,7 @@ def _log_integration(msg): if hook_result_cursor.hooks_integrated > 0: result["hooks"] += hook_result_cursor.hooks_integrated _log_integration(f" └─ {hook_result_cursor.hooks_integrated} hook(s) integrated -> .cursor/hooks.json") + _log_hook_details(hook_result_cursor) for tp in hook_result_cursor.target_paths: deployed.append(tp.relative_to(project_root).as_posix()) @@ -2092,4 +2116,3 @@ def _collect_descendants(node, visited=None): - diff --git a/src/apm_cli/integration/hook_integrator.py b/src/apm_cli/integration/hook_integrator.py index 0ace6ade..f0041649 100644 --- a/src/apm_cli/integration/hook_integrator.py +++ b/src/apm_cli/integration/hook_integrator.py @@ -46,7 +46,7 @@ import re import shutil from pathlib import Path -from typing import List, Dict, Tuple, Optional +from typing import Any, List, Dict, Tuple, Optional from dataclasses import dataclass, field from apm_cli.integration.base_integrator import BaseIntegrator @@ -58,6 +58,7 @@ class HookIntegrationResult: hooks_integrated: int scripts_copied: int target_paths: List[Path] = field(default_factory=list) + display_payloads: List[Dict[str, Any]] = field(default_factory=list) class HookIntegrator(BaseIntegrator): @@ -70,6 +71,75 @@ class HookIntegrator(BaseIntegrator): - Cursor: Merged into .cursor/hooks.json hooks key + .cursor/hooks// """ + @staticmethod + def _iter_hook_entries(payload: Dict) -> List[Tuple[str, Dict]]: + """Flatten hook payloads into ``(event_name, entry_dict)`` pairs.""" + entries: List[Tuple[str, Dict]] = [] + hooks = payload.get("hooks", {}) + if not isinstance(hooks, dict): + return entries + + for event_name, matchers in hooks.items(): + if not isinstance(matchers, list): + continue + for matcher in matchers: + if not isinstance(matcher, dict): + continue + + for key in ("command", "bash", "powershell"): + value = matcher.get(key) + if isinstance(value, str): + entries.append((event_name, {key: value})) + + nested_hooks = matcher.get("hooks", []) + if not isinstance(nested_hooks, list): + continue + for hook in nested_hooks: + if not isinstance(hook, dict): + continue + for key in ("command", "bash", "powershell"): + value = hook.get(key) + if isinstance(value, str): + entries.append((event_name, {key: value})) + return entries + + @staticmethod + def _summarize_command(entry: Dict) -> str: + """Return a human-readable summary for a single hook command entry.""" + command = "" + for key in ("command", "bash", "powershell"): + value = entry.get(key) + if isinstance(value, str) and value.strip(): + command = value.strip() + break + + if not command: + return "runs hook command" + + for token in command.split(): + cleaned = token.strip("\"'") + if "/" in cleaned or cleaned.startswith("."): + return f"runs {cleaned}" + + return f"runs {command}" + + def _build_display_payload(self, target_label: str, output_path: str, source_hook_file: Path, rewritten: Dict) -> Dict[str, Any]: + """Build CLI display metadata for an integrated hook file.""" + actions = [] + for event_name, entry in self._iter_hook_entries(rewritten): + actions.append({ + "event": event_name, + "summary": self._summarize_command(entry), + }) + + return { + "target_label": target_label, + "output_path": output_path, + "source_hook_file": source_hook_file.name, + "actions": actions, + "rendered_json": json.dumps(rewritten, indent=2, sort_keys=True), + } + def find_hook_files(self, package_path: Path) -> List[Path]: """Find all hook JSON files in a package. @@ -297,6 +367,7 @@ def integrate_package_hooks(self, package_info, project_root: Path, hooks_integrated = 0 scripts_copied = 0 target_paths: List[Path] = [] + display_payloads: List[Dict[str, Any]] = [] for hook_file in hook_files: data = self._parse_hook_json(hook_file) @@ -325,6 +396,14 @@ def integrate_package_hooks(self, package_info, project_root: Path, hooks_integrated += 1 target_paths.append(target_path) + display_payloads.append( + self._build_display_payload( + ".github/hooks/", + target_filename, + hook_file, + rewritten, + ) + ) # Copy referenced scripts (individual file tracking) for source_file, target_rel in scripts: @@ -340,6 +419,7 @@ def integrate_package_hooks(self, package_info, project_root: Path, hooks_integrated=hooks_integrated, scripts_copied=scripts_copied, target_paths=target_paths, + display_payloads=display_payloads, ) def integrate_package_hooks_claude(self, package_info, project_root: Path, @@ -373,6 +453,7 @@ def integrate_package_hooks_claude(self, package_info, project_root: Path, hooks_integrated = 0 scripts_copied = 0 target_paths: List[Path] = [] + display_payloads: List[Dict[str, Any]] = [] # Read existing settings settings_path = project_root / ".claude" / "settings.json" @@ -414,6 +495,14 @@ def integrate_package_hooks_claude(self, package_info, project_root: Path, settings["hooks"][event_name].extend(matchers) hooks_integrated += 1 + display_payloads.append( + self._build_display_payload( + ".claude/settings.json", + ".claude/settings.json", + hook_file, + rewritten, + ) + ) # Copy referenced scripts for source_file, target_rel in scripts: @@ -437,6 +526,7 @@ def integrate_package_hooks_claude(self, package_info, project_root: Path, hooks_integrated=hooks_integrated, scripts_copied=scripts_copied, target_paths=target_paths, + display_payloads=display_payloads, ) def integrate_package_hooks_cursor(self, package_info, project_root: Path, @@ -478,6 +568,7 @@ def integrate_package_hooks_cursor(self, package_info, project_root: Path, hooks_integrated = 0 scripts_copied = 0 target_paths: List[Path] = [] + display_payloads: List[Dict[str, Any]] = [] # Read existing hooks.json hooks_json_path = project_root / ".cursor" / "hooks.json" @@ -519,6 +610,14 @@ def integrate_package_hooks_cursor(self, package_info, project_root: Path, hooks_config["hooks"][event_name].extend(entries) hooks_integrated += 1 + display_payloads.append( + self._build_display_payload( + ".cursor/hooks.json", + ".cursor/hooks.json", + hook_file, + rewritten, + ) + ) # Copy referenced scripts for source_file, target_rel in scripts: @@ -542,6 +641,7 @@ def integrate_package_hooks_cursor(self, package_info, project_root: Path, hooks_integrated=hooks_integrated, scripts_copied=scripts_copied, target_paths=target_paths, + display_payloads=display_payloads, ) def sync_integration(self, apm_package, project_root: Path, @@ -674,4 +774,3 @@ def _clean_apm_entries_from_json(json_path: Path, stats: Dict[str, int]) -> None stats['files_removed'] += 1 except (json.JSONDecodeError, OSError): stats['errors'] += 1 - diff --git a/tests/unit/test_install_hook_transparency.py b/tests/unit/test_install_hook_transparency.py new file mode 100644 index 00000000..80cd981a --- /dev/null +++ b/tests/unit/test_install_hook_transparency.py @@ -0,0 +1,164 @@ +import json +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +from apm_cli.commands.install import _integrate_package_primitives +from apm_cli.integration.hook_integrator import HookIntegrator +from apm_cli.models.apm_package import APMPackage, PackageInfo + + +def _empty_result(**overrides): + payload = { + "files_integrated": 0, + "files_updated": 0, + "links_resolved": 0, + "target_paths": [], + "skill_created": False, + "sub_skills_promoted": 0, + } + payload.update(overrides) + return SimpleNamespace(**payload) + + +class _NoopPromptIntegrator: + def integrate_package_prompts(self, *args, **kwargs): + return _empty_result() + + +class _NoopAgentIntegrator: + def integrate_package_agents(self, *args, **kwargs): + return _empty_result() + + def integrate_package_agents_claude(self, *args, **kwargs): + return _empty_result() + + def integrate_package_agents_cursor(self, *args, **kwargs): + return _empty_result() + + def integrate_package_agents_opencode(self, *args, **kwargs): + return _empty_result() + + +class _NoopSkillIntegrator: + def integrate_package_skill(self, *args, **kwargs): + return _empty_result() + + +class _NoopInstructionIntegrator: + def integrate_package_instructions(self, *args, **kwargs): + return _empty_result() + + def integrate_package_instructions_cursor(self, *args, **kwargs): + return _empty_result() + + +class _NoopCommandIntegrator: + def integrate_package_commands(self, *args, **kwargs): + return _empty_result() + + def integrate_package_commands_opencode(self, *args, **kwargs): + return _empty_result() + + +def _make_package_info(install_path: Path, name: str = "hookify") -> PackageInfo: + package = APMPackage(name=name, version="1.0.0") + return PackageInfo(package=package, install_path=install_path) + + +def _setup_hook_package(project_root: Path) -> PackageInfo: + pkg_dir = project_root / "apm_modules" / "anthropics" / "hookify" + hooks_dir = pkg_dir / "hooks" + hooks_dir.mkdir(parents=True) + + (hooks_dir / "hooks.json").write_text( + json.dumps( + { + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/pretooluse.py", + "timeout": 10, + } + ] + } + ] + } + } + ) + ) + (hooks_dir / "pretooluse.py").write_text("#!/usr/bin/env python3\nprint('ok')\n") + return _make_package_info(pkg_dir) + + +def test_install_logs_hook_action_summary(tmp_path): + package_info = _setup_hook_package(tmp_path) + logger = MagicMock() + logger.verbose = False + + _integrate_package_primitives( + package_info, + tmp_path, + integrate_vscode=True, + integrate_claude=False, + integrate_opencode=False, + prompt_integrator=_NoopPromptIntegrator(), + agent_integrator=_NoopAgentIntegrator(), + skill_integrator=_NoopSkillIntegrator(), + instruction_integrator=_NoopInstructionIntegrator(), + command_integrator=_NoopCommandIntegrator(), + hook_integrator=HookIntegrator(), + force=False, + managed_files=set(), + diagnostics=None, + package_name="anthropics/hookify", + logger=logger, + ) + + tree_lines = [call.args[0] for call in logger.tree_item.call_args_list] + assert any("1 hook(s) integrated -> .github/hooks/" in line for line in tree_lines) + assert any( + "PreToolUse: runs .github/hooks/scripts/hookify/hooks/pretooluse.py (hooks.json)" + in line + for line in tree_lines + ) + logger.verbose_detail.assert_not_called() + + +def test_install_logs_full_hook_json_in_verbose_mode(tmp_path): + package_info = _setup_hook_package(tmp_path) + (tmp_path / ".claude").mkdir() + logger = MagicMock() + logger.verbose = True + + _integrate_package_primitives( + package_info, + tmp_path, + integrate_vscode=False, + integrate_claude=True, + integrate_opencode=False, + prompt_integrator=_NoopPromptIntegrator(), + agent_integrator=_NoopAgentIntegrator(), + skill_integrator=_NoopSkillIntegrator(), + instruction_integrator=_NoopInstructionIntegrator(), + command_integrator=_NoopCommandIntegrator(), + hook_integrator=HookIntegrator(), + force=False, + managed_files=set(), + diagnostics=None, + package_name="anthropics/hookify", + logger=logger, + ) + + verbose_lines = [call.args[0] for call in logger.verbose_detail.call_args_list] + assert any( + "Hook JSON (hooks.json -> .claude/settings.json):" in line + for line in verbose_lines + ) + assert any( + '"command": "python3 .claude/hooks/hookify/hooks/pretooluse.py"' in line + for line in verbose_lines + )