Skip to content
Open
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}")
Comment on lines +794 to +799
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new verbose hook transparency (Hook JSON ... + full rewritten JSON) changes what --verbose emits during apm install, but the CLI docs currently describe verbose mode only in terms of file paths + diagnostic details. Please update the Starlight docs (e.g. docs/src/content/docs/reference/cli-commands.md under apm install) to mention that verbose mode also prints the full rewritten hook JSON so users can review deployed hook content.

Copilot uses AI. Check for mistakes.

# --- 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),
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The verbose display payload uses json.dumps(..., sort_keys=True), but the deployed JSON files are written with json.dump(..., indent=2) (no sort_keys). This means --verbose will show a key order that can differ from what was actually deployed, which undermines the goal of letting developers review the exact hook content. Align the verbose rendering with the on-disk serialization (or reuse the same serialization helper for both).

Suggested change
"rendered_json": json.dumps(rewritten, indent=2, sort_keys=True),
"rendered_json": json.dumps(rewritten, indent=2),

Copilot uses AI. Check for mistakes.
}
Comment on lines +135 to +141
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rendered_json is precomputed for every integrated hook via json.dumps(...), even when apm install is not in --verbose mode. This adds avoidable work/memory on the hot install path. Consider deferring JSON rendering until verbose logging is actually requested (e.g., store the rewritten dict, or store a callable/None and render in the logger code only when needed).

Copilot uses AI. Check for mistakes.

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,
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For VSCode/GitHub hooks, the display payload sets output_path to target_filename only. This makes the verbose line render as hooks.json -> <filename> without the .github/hooks/ prefix, unlike the Claude/Cursor cases that include the full target path. Consider storing the full relative destination path (e.g. .github/hooks/<filename>) or using target_label when formatting the destination so the output is unambiguous.

Suggested change
target_filename,
rel_path,

Copilot uses AI. Check for mistakes.
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
Loading