Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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())

Expand All @@ -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())

Expand Down Expand Up @@ -2092,4 +2116,3 @@ def _collect_descendants(node, visited=None):




103 changes: 101 additions & 2 deletions src/apm_cli/integration/hook_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -70,6 +71,75 @@ class HookIntegrator(BaseIntegrator):
- Cursor: Merged into .cursor/hooks.json hooks key + .cursor/hooks/<pkg>/
"""

@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.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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

Loading