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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

## [0.8.9] - 2026-03-31

### Fixed
Expand Down
25 changes: 22 additions & 3 deletions src/apm_cli/integration/hook_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"""

import json
import logging
import re
import shutil
from pathlib import Path
Expand All @@ -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:
Expand All @@ -71,6 +74,12 @@ class HookIntegrator(BaseIntegrator):
- Cursor: Merged into .cursor/hooks.json hooks key + .cursor/hooks/<pkg>/
"""

# Keys in hook JSON dicts that may contain script-path references.
# Extend this tuple when new platform keys are introduced -- every
# call site in _rewrite_hooks_data() iterates over it, so a single
# addition here propagates everywhere.
HOOK_COMMAND_KEYS: Tuple[str, ...] = ("command", "bash", "powershell", "windows")

def find_hook_files(self, package_path: Path) -> List[Path]:
"""Find all hook JSON files in a package.

Expand Down Expand Up @@ -230,13 +239,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)
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

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

_rewrite_hooks_data() can accumulate duplicate (source_file, target_rel) entries when multiple keys reference the same script (e.g., command and bash pointing to the same ./scripts/run.sh). Those duplicates later trigger redundant copy attempts and can produce false "local file exists" collision skips because managed_files is not updated during the integration loop. Consider de-duplicating scripts by target_rel (or source+target) before extending/returning.

This issue also appears on line 274 of the same file.

Copilot uses AI. Check for mistakes.

Expand All @@ -245,12 +259,17 @@ 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)

Expand Down
123 changes: 123 additions & 0 deletions tests/unit/integration/test_hook_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1218,6 +1218,129 @@ 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_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_all_platform_keys(self, temp_project):
"""Test _rewrite_hooks_data handles all 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'")

data = {
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"command": "./scripts/run.sh",
"bash": "./scripts/run.sh",
"powershell": "./scripts/run.ps1",
"windows": "./scripts/run-win.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/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"]
# Each key independently produces a copy entry (command and bash
# reference the same source file but both emit an entry).
assert len(scripts) == 4
script_targets = [t for _, t in scripts]
assert script_targets.count(".github/hooks/scripts/my-pkg/scripts/run.sh") == 2
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

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"
Expand Down
Loading
Loading