diff --git a/CHANGELOG.md b/CHANGELOG.md index cd2d1a00e..e8dd27229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Hook integrator now processes the `windows` property in hook JSON files, copying referenced scripts and rewriting paths during install/compile (#311) + ### Added - `apm install` now deploys `.instructions.md` files to `.claude/rules/*.md` for Claude Code, converting `applyTo:` frontmatter to Claude's `paths:` format (#516) diff --git a/src/apm_cli/integration/hook_integrator.py b/src/apm_cli/integration/hook_integrator.py index 9ff9e55eb..d73540459 100644 --- a/src/apm_cli/integration/hook_integrator.py +++ b/src/apm_cli/integration/hook_integrator.py @@ -43,6 +43,7 @@ """ import json +import logging import re import shutil from pathlib import Path @@ -52,6 +53,8 @@ from apm_cli.integration.base_integrator import BaseIntegrator from apm_cli.utils.paths import portable_relpath +_log = logging.getLogger(__name__) + @dataclass class HookIntegrationResult: @@ -71,6 +74,23 @@ class HookIntegrator(BaseIntegrator): - Cursor: Merged into .cursor/hooks.json hooks key + .cursor/hooks// """ + # Superset of all known script-path keys across supported hook specs. + # Every call site in _rewrite_hooks_data() iterates over this tuple, + # so a single addition here propagates everywhere. + # + # "command": Claude Code (primary), VS Code (default/cross-platform), Cursor + # "bash": GitHub Copilot Agent cloud/CLI + # "powershell": GitHub Copilot Agent cloud/CLI + # "windows": VS Code (OS-specific override) + # "linux": VS Code (OS-specific override) + # "osx": VS Code (OS-specific override) + # + # Refs: + # GH Copilot Agent: https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks + # VS Code: https://code.visualstudio.com/docs/copilot/customization/hooks + # Claude Code: https://code.claude.com/docs/en/hooks + HOOK_COMMAND_KEYS: Tuple[str, ...] = ("command", "bash", "powershell", "windows", "linux", "osx") + def find_hook_files(self, package_path: Path) -> List[Path]: """Find all hook JSON files in a package. @@ -230,13 +250,18 @@ def _rewrite_hooks_data( if not isinstance(matcher, dict): continue # Rewrite script paths in the matcher dict itself - # (GitHub Copilot flat format: bash/powershell keys at this level) - for key in ("command", "bash", "powershell"): + # (GitHub Copilot flat format: bash/powershell/windows keys at this level) + for key in self.HOOK_COMMAND_KEYS: if key in matcher: new_cmd, scripts = self._rewrite_command_for_target( matcher[key], package_path, package_name, target, hook_file_dir=hook_file_dir, ) + if scripts: + _log.debug( + "Hook %s/%s: rewrote '%s' key (%d script(s))", + package_name, event_name, key, len(scripts), + ) matcher[key] = new_cmd all_scripts.extend(scripts) @@ -245,16 +270,29 @@ def _rewrite_hooks_data( for hook in matcher.get("hooks", []): if not isinstance(hook, dict): continue - for key in ("command", "bash", "powershell"): + for key in self.HOOK_COMMAND_KEYS: if key in hook: new_cmd, scripts = self._rewrite_command_for_target( hook[key], package_path, package_name, target, hook_file_dir=hook_file_dir, ) + if scripts: + _log.debug( + "Hook %s/%s: rewrote '%s' key (%d script(s))", + package_name, event_name, key, len(scripts), + ) hook[key] = new_cmd all_scripts.extend(scripts) - return rewritten, all_scripts + # De-duplicate by target path to avoid redundant copies when + # multiple keys (e.g. command + bash) reference the same script. + seen_targets: dict[str, Path] = {} + for source, target_rel in all_scripts: + if target_rel not in seen_targets: + seen_targets[target_rel] = source + unique_scripts = [(src, tgt) for tgt, src in seen_targets.items()] + + return rewritten, unique_scripts def _get_package_name(self, package_info) -> str: """Get a short package name for use in file/directory naming. diff --git a/tests/unit/integration/test_hook_integrator.py b/tests/unit/integration/test_hook_integrator.py index 744e59202..47aa9175c 100644 --- a/tests/unit/integration/test_hook_integrator.py +++ b/tests/unit/integration/test_hook_integrator.py @@ -1218,6 +1218,299 @@ def test_rewrite_powershell_key(self, temp_project): assert ".github/hooks/scripts/my-pkg/scripts/check.ps1" in cmd assert len(scripts) == 1 + def test_rewrite_windows_key(self, temp_project): + """Test rewriting the windows key (GitHub Copilot format).""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "scan-secrets.ps1").write_text("Write-Host 'scanning'") + + integrator = HookIntegrator() + cmd, scripts = integrator._rewrite_command_for_target( + "./scripts/scan-secrets.ps1", + pkg_dir, + "my-pkg", + "vscode", + ) + + assert "./" not in cmd + assert ".github/hooks/scripts/my-pkg/scripts/scan-secrets.ps1" in cmd + assert len(scripts) == 1 + + def test_rewrite_linux_key(self, temp_project): + """Test rewriting the linux key (VS Code OS-specific override).""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "validate.sh").write_text("#!/bin/bash") + + integrator = HookIntegrator() + cmd, scripts = integrator._rewrite_command_for_target( + "./scripts/validate.sh", + pkg_dir, + "my-pkg", + "vscode", + ) + + assert "./" not in cmd + assert ".github/hooks/scripts/my-pkg/scripts/validate.sh" in cmd + assert len(scripts) == 1 + + def test_rewrite_osx_key(self, temp_project): + """Test rewriting the osx key (VS Code OS-specific override).""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "format-mac.sh").write_text("#!/bin/bash") + + integrator = HookIntegrator() + cmd, scripts = integrator._rewrite_command_for_target( + "./scripts/format-mac.sh", + pkg_dir, + "my-pkg", + "vscode", + ) + + assert "./" not in cmd + assert ".github/hooks/scripts/my-pkg/scripts/format-mac.sh" in cmd + assert len(scripts) == 1 + + def test_rewrite_hooks_data_windows_flat_format(self, temp_project): + """Test _rewrite_hooks_data handles windows key in flat format (GitHub Copilot).""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "validate.sh").write_text("#!/bin/bash") + (pkg_dir / "scripts" / "validate.ps1").write_text("Write-Host 'ok'") + + data = { + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "./scripts/validate.sh", + "windows": "./scripts/validate.ps1", + } + ] + } + } + + integrator = HookIntegrator() + rewritten, scripts = integrator._rewrite_hooks_data( + data, pkg_dir, "my-pkg", "vscode", + ) + + hook = rewritten["hooks"]["preToolUse"][0] + assert ".github/hooks/scripts/my-pkg/scripts/validate.sh" in hook["bash"] + assert ".github/hooks/scripts/my-pkg/scripts/validate.ps1" in hook["windows"] + assert len(scripts) == 2 + + def test_rewrite_hooks_data_windows_nested_format(self, temp_project): + """Test _rewrite_hooks_data handles windows key in nested format (Claude-style).""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "validate.sh").write_text("#!/bin/bash") + (pkg_dir / "scripts" / "validate.ps1").write_text("Write-Host 'ok'") + + data = { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "./scripts/validate.sh", + "windows": "./scripts/validate.ps1", + } + ] + } + ] + } + } + + integrator = HookIntegrator() + rewritten, scripts = integrator._rewrite_hooks_data( + data, pkg_dir, "my-pkg", "vscode", + ) + + hook = rewritten["hooks"]["PreToolUse"][0]["hooks"][0] + assert ".github/hooks/scripts/my-pkg/scripts/validate.sh" in hook["command"] + assert ".github/hooks/scripts/my-pkg/scripts/validate.ps1" in hook["windows"] + assert len(scripts) == 2 + + def test_rewrite_hooks_data_linux_flat_format(self, temp_project): + """Test _rewrite_hooks_data handles linux key in flat format (VS Code).""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "format.sh").write_text("#!/bin/bash") + (pkg_dir / "scripts" / "format-linux.sh").write_text("#!/bin/bash") + + data = { + "hooks": { + "PostToolUse": [ + { + "type": "command", + "command": "./scripts/format.sh", + "linux": "./scripts/format-linux.sh", + } + ] + } + } + + integrator = HookIntegrator() + rewritten, scripts = integrator._rewrite_hooks_data( + data, pkg_dir, "my-pkg", "vscode", + ) + + hook = rewritten["hooks"]["PostToolUse"][0] + assert ".github/hooks/scripts/my-pkg/scripts/format.sh" in hook["command"] + assert ".github/hooks/scripts/my-pkg/scripts/format-linux.sh" in hook["linux"] + assert len(scripts) == 2 + + def test_rewrite_hooks_data_linux_nested_format(self, temp_project): + """Test _rewrite_hooks_data handles linux key in nested format (Claude-style).""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "validate.sh").write_text("#!/bin/bash") + (pkg_dir / "scripts" / "validate-linux.sh").write_text("#!/bin/bash") + + data = { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "./scripts/validate.sh", + "linux": "./scripts/validate-linux.sh", + } + ] + } + ] + } + } + + integrator = HookIntegrator() + rewritten, scripts = integrator._rewrite_hooks_data( + data, pkg_dir, "my-pkg", "vscode", + ) + + hook = rewritten["hooks"]["PreToolUse"][0]["hooks"][0] + assert ".github/hooks/scripts/my-pkg/scripts/validate.sh" in hook["command"] + assert ".github/hooks/scripts/my-pkg/scripts/validate-linux.sh" in hook["linux"] + assert len(scripts) == 2 + + def test_rewrite_hooks_data_osx_flat_format(self, temp_project): + """Test _rewrite_hooks_data handles osx key in flat format (VS Code).""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "format.sh").write_text("#!/bin/bash") + (pkg_dir / "scripts" / "format-mac.sh").write_text("#!/bin/bash") + + data = { + "hooks": { + "PostToolUse": [ + { + "type": "command", + "command": "./scripts/format.sh", + "osx": "./scripts/format-mac.sh", + } + ] + } + } + + integrator = HookIntegrator() + rewritten, scripts = integrator._rewrite_hooks_data( + data, pkg_dir, "my-pkg", "vscode", + ) + + hook = rewritten["hooks"]["PostToolUse"][0] + assert ".github/hooks/scripts/my-pkg/scripts/format.sh" in hook["command"] + assert ".github/hooks/scripts/my-pkg/scripts/format-mac.sh" in hook["osx"] + assert len(scripts) == 2 + + def test_rewrite_hooks_data_osx_nested_format(self, temp_project): + """Test _rewrite_hooks_data handles osx key in nested format (Claude-style).""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "validate.sh").write_text("#!/bin/bash") + (pkg_dir / "scripts" / "validate-mac.sh").write_text("#!/bin/bash") + + data = { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "./scripts/validate.sh", + "osx": "./scripts/validate-mac.sh", + } + ] + } + ] + } + } + + integrator = HookIntegrator() + rewritten, scripts = integrator._rewrite_hooks_data( + data, pkg_dir, "my-pkg", "vscode", + ) + + hook = rewritten["hooks"]["PreToolUse"][0]["hooks"][0] + assert ".github/hooks/scripts/my-pkg/scripts/validate.sh" in hook["command"] + assert ".github/hooks/scripts/my-pkg/scripts/validate-mac.sh" in hook["osx"] + assert len(scripts) == 2 + + def test_rewrite_hooks_data_all_platform_keys(self, temp_project): + """Test _rewrite_hooks_data handles all 6 platform keys together.""" + pkg_dir = temp_project / "pkg" + (pkg_dir / "scripts").mkdir(parents=True) + (pkg_dir / "scripts" / "run.sh").write_text("#!/bin/bash") + (pkg_dir / "scripts" / "run.ps1").write_text("Write-Host 'ok'") + (pkg_dir / "scripts" / "run-win.ps1").write_text("Write-Host 'win'") + (pkg_dir / "scripts" / "run-linux.sh").write_text("#!/bin/bash") + (pkg_dir / "scripts" / "run-mac.sh").write_text("#!/bin/bash") + + data = { + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "command": "./scripts/run.sh", + "bash": "./scripts/run.sh", + "powershell": "./scripts/run.ps1", + "windows": "./scripts/run-win.ps1", + "linux": "./scripts/run-linux.sh", + "osx": "./scripts/run-mac.sh", + } + ] + } + } + + integrator = HookIntegrator() + rewritten, scripts = integrator._rewrite_hooks_data( + data, pkg_dir, "my-pkg", "vscode", + ) + + hook = rewritten["hooks"]["preToolUse"][0] + assert ".github/hooks/scripts/my-pkg/scripts/run.sh" in hook["command"] + assert ".github/hooks/scripts/my-pkg/scripts/run.sh" in hook["bash"] + assert ".github/hooks/scripts/my-pkg/scripts/run.ps1" in hook["powershell"] + assert ".github/hooks/scripts/my-pkg/scripts/run-win.ps1" in hook["windows"] + assert ".github/hooks/scripts/my-pkg/scripts/run-linux.sh" in hook["linux"] + assert ".github/hooks/scripts/my-pkg/scripts/run-mac.sh" in hook["osx"] + # Scripts are de-duplicated by target path. command and bash both + # reference run.sh with the same target, so only 5 unique entries. + assert len(scripts) == 5 + script_targets = [t for _, t in scripts] + assert script_targets.count(".github/hooks/scripts/my-pkg/scripts/run.sh") == 1 + assert script_targets.count(".github/hooks/scripts/my-pkg/scripts/run.ps1") == 1 + assert script_targets.count(".github/hooks/scripts/my-pkg/scripts/run-win.ps1") == 1 + assert script_targets.count(".github/hooks/scripts/my-pkg/scripts/run-linux.sh") == 1 + assert script_targets.count(".github/hooks/scripts/my-pkg/scripts/run-mac.sh") == 1 + def test_rewrite_hooks_data_github_copilot_flat_format(self, temp_project): """Test _rewrite_hooks_data handles GitHub Copilot flat format (bash/powershell at top level).""" pkg_dir = temp_project / "pkg"