Skip to content

Commit 3752b60

Browse files
sergio-sisternes-epamdanielmeppielCopilot
authored
fix: scope-aware hook deployment, dynamic sync prefixes, auto_create guard (#566)
Rework of PR #566 on top of post-#562 architecture: - integrate_package_hooks: accept target=, use target.root_dir instead of hardcoded .github for Copilot hook deployment path - _integrate_merged_hooks: use target.root_dir instead of f'.{config.target_key}' for Claude/Cursor/Codex JSON merge path - _rewrite_command_for_target / _rewrite_hooks_data: accept root_dir= override so script paths match the scope-resolved directory - sync_integration: derive hook prefixes dynamically from KNOWN_TARGETS instead of hardcoded .github/.claude/.cursor/.codex paths; accept targets= parameter - install.py: gate directory creation on auto_create=True to prevent creating dirs for targets that don't want them (e.g. Claude, Cursor at project scope) - 6 new tests covering scope-resolved deploy, script rewrite, sync cleanup, and auto_create guard Fixes #565 Addresses #576 (P1-1 sync, P1-4 auto_create) Co-authored-by: danielmeppiel <dmeppiel@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fb20bf7 commit 3752b60

File tree

4 files changed

+232
-68
lines changed

4 files changed

+232
-68
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616

1717
### Fixed
1818

19+
- `apm install -g` now deploys hooks to the scope-resolved target directory (e.g. `~/.copilot/hooks/`) instead of hardcoding `.github/hooks/` (#565)
20+
- Hook sync/cleanup now derives prefixes dynamically from `KNOWN_TARGETS` instead of hardcoded paths (#565)
21+
- `auto_create=False` targets no longer get directories unconditionally created during install (#576)
1922
- `apm deps update -g` now correctly passes scope, preventing user-scope updates from silently using project-scope paths (#562)
2023

2124
## [0.8.10] - 2026-04-03

src/apm_cli/commands/install.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1431,6 +1431,8 @@ def _collect_descendants(node, visited=None):
14311431
)
14321432

14331433
for _t in _targets:
1434+
if not _t.auto_create:
1435+
continue
14341436
_root = _t.root_dir
14351437
_target_dir = project_root / _root
14361438
if not _target_dir.exists():

src/apm_cli/integration/hook_integrator.py

Lines changed: 91 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ def _rewrite_command_for_target(
192192
package_name: str,
193193
target: str,
194194
hook_file_dir: Optional[Path] = None,
195+
root_dir: Optional[str] = None,
195196
) -> Tuple[str, List[Tuple[Path, str]]]:
196197
"""Rewrite a hook command to use installed script paths.
197198
@@ -205,6 +206,7 @@ def _rewrite_command_for_target(
205206
package_name: Name used for the scripts subdirectory
206207
target: "vscode" or "claude"
207208
hook_file_dir: Directory containing the hook JSON file (for ./path resolution)
209+
root_dir: Override root directory (e.g. ".copilot" for user scope)
208210
209211
Returns:
210212
Tuple of (rewritten_command, list of (source_file, relative_target_path))
@@ -213,13 +215,17 @@ def _rewrite_command_for_target(
213215
new_command = command
214216

215217
if target == "vscode":
216-
scripts_base = f".github/hooks/scripts/{package_name}"
218+
base_root = root_dir or ".github"
219+
scripts_base = f"{base_root}/hooks/scripts/{package_name}"
217220
elif target == "cursor":
218-
scripts_base = f".cursor/hooks/{package_name}"
221+
base_root = root_dir or ".cursor"
222+
scripts_base = f"{base_root}/hooks/{package_name}"
219223
elif target == "codex":
220-
scripts_base = f".codex/hooks/{package_name}"
224+
base_root = root_dir or ".codex"
225+
scripts_base = f"{base_root}/hooks/{package_name}"
221226
else:
222-
scripts_base = f".claude/hooks/{package_name}"
227+
base_root = root_dir or ".claude"
228+
scripts_base = f"{base_root}/hooks/{package_name}"
223229

224230
# Handle ${CLAUDE_PLUGIN_ROOT} references (always relative to package root)
225231
plugin_root_pattern = r'\$\{CLAUDE_PLUGIN_ROOT\}(/[^\s]+)'
@@ -263,6 +269,7 @@ def _rewrite_hooks_data(
263269
package_name: str,
264270
target: str,
265271
hook_file_dir: Optional[Path] = None,
272+
root_dir: Optional[str] = None,
266273
) -> Tuple[Dict, List[Tuple[Path, str]]]:
267274
"""Rewrite all command paths in a hooks JSON structure.
268275
@@ -274,6 +281,7 @@ def _rewrite_hooks_data(
274281
package_name: Name for scripts subdirectory
275282
target: "vscode" or "claude"
276283
hook_file_dir: Directory containing the hook JSON file (for ./path resolution)
284+
root_dir: Override root directory (e.g. ".copilot" for user scope)
277285
278286
Returns:
279287
Tuple of (rewritten_data_copy, list of (source_file, target_rel_path))
@@ -296,6 +304,7 @@ def _rewrite_hooks_data(
296304
new_cmd, scripts = self._rewrite_command_for_target(
297305
matcher[key], package_path, package_name, target,
298306
hook_file_dir=hook_file_dir,
307+
root_dir=root_dir,
299308
)
300309
if scripts:
301310
_log.debug(
@@ -315,6 +324,7 @@ def _rewrite_hooks_data(
315324
new_cmd, scripts = self._rewrite_command_for_target(
316325
hook[key], package_path, package_name, target,
317326
hook_file_dir=hook_file_dir,
327+
root_dir=root_dir,
318328
)
319329
if scripts:
320330
_log.debug(
@@ -348,8 +358,9 @@ def _get_package_name(self, package_info) -> str:
348358
def integrate_package_hooks(self, package_info, project_root: Path,
349359
force: bool = False,
350360
managed_files: set = None,
351-
diagnostics=None) -> HookIntegrationResult:
352-
"""Integrate hooks from a package into .github/hooks/ (VSCode target).
361+
diagnostics=None,
362+
target=None) -> HookIntegrationResult:
363+
"""Integrate hooks from a package into hooks dir (Copilot target).
353364
354365
Deploys hook JSON files with clean filenames and copies referenced
355366
script files. Skips user-authored files unless force=True.
@@ -359,6 +370,7 @@ def integrate_package_hooks(self, package_info, project_root: Path,
359370
project_root: Root directory of the project
360371
force: If True, overwrite user-authored files on collision
361372
managed_files: Set of relative paths known to be APM-managed
373+
target: Optional TargetProfile for scope-resolved root_dir
362374
363375
Returns:
364376
HookIntegrationResult: Results of the integration operation
@@ -371,7 +383,8 @@ def integrate_package_hooks(self, package_info, project_root: Path,
371383
files_skipped=0, target_paths=[],
372384
)
373385

374-
hooks_dir = project_root / ".github" / "hooks"
386+
root_dir = target.root_dir if target else ".github"
387+
hooks_dir = project_root / root_dir / "hooks"
375388
hooks_dir.mkdir(parents=True, exist_ok=True)
376389

377390
package_name = self._get_package_name(package_info)
@@ -388,6 +401,7 @@ def integrate_package_hooks(self, package_info, project_root: Path,
388401
rewritten, scripts = self._rewrite_hooks_data(
389402
data, package_info.install_path, package_name, "vscode",
390403
hook_file_dir=hook_file.parent,
404+
root_dir=root_dir,
391405
)
392406

393407
# Generate target filename (clean, no -apm suffix)
@@ -436,6 +450,7 @@ def _integrate_merged_hooks(
436450
force: bool = False,
437451
managed_files: set = None,
438452
diagnostics=None,
453+
target=None,
439454
) -> HookIntegrationResult:
440455
"""Integrate hooks by merging into a target-specific JSON config.
441456
@@ -448,7 +463,8 @@ def _integrate_merged_hooks(
448463
files_skipped=0, target_paths=[],
449464
)
450465

451-
target_dir = project_root / f".{config.target_key}"
466+
root_dir = target.root_dir if target else f".{config.target_key}"
467+
target_dir = project_root / root_dir
452468

453469
# Opt-in check: some targets only deploy when their dir exists
454470
if config.require_dir and not target_dir.exists():
@@ -486,6 +502,7 @@ def _integrate_merged_hooks(
486502
data, package_info.install_path, package_name,
487503
config.target_key,
488504
hook_file_dir=hook_file.parent,
505+
root_dir=root_dir,
489506
)
490507

491508
# Merge hooks into config (additive)
@@ -603,6 +620,7 @@ def integrate_hooks_for_target(
603620
package_info, project_root,
604621
force=force, managed_files=managed_files,
605622
diagnostics=diagnostics,
623+
target=target,
606624
)
607625

608626
config = _MERGE_HOOK_TARGETS.get(target.name)
@@ -611,6 +629,7 @@ def integrate_hooks_for_target(
611629
config, package_info, project_root,
612630
force=force, managed_files=managed_files,
613631
diagnostics=diagnostics,
632+
target=target,
614633
)
615634

616635
return HookIntegrationResult(
@@ -619,7 +638,8 @@ def integrate_hooks_for_target(
619638
)
620639

621640
def sync_integration(self, apm_package, project_root: Path,
622-
managed_files: set = None) -> Dict:
641+
managed_files: set = None,
642+
targets=None) -> Dict:
623643
"""Remove APM-managed hook files.
624644
625645
Uses *managed_files* (relative paths) to surgically remove only
@@ -628,35 +648,41 @@ def sync_integration(self, apm_package, project_root: Path,
628648
629649
**Never** calls ``shutil.rmtree``.
630650
631-
Also cleans APM entries from ``.claude/settings.json`` and
632-
``.cursor/hooks.json`` via the ``_apm_source`` marker.
651+
Also cleans APM entries from merged-hook JSON files via the
652+
``_apm_source`` marker.
633653
"""
654+
from .targets import KNOWN_TARGETS
655+
634656
stats: Dict[str, int] = {'files_removed': 0, 'errors': 0}
635657

658+
# Derive hook prefixes dynamically from targets
659+
source = targets if targets is not None else list(KNOWN_TARGETS.values())
660+
hook_prefixes = []
661+
for t in source:
662+
if t.supports("hooks"):
663+
sm = t.primitives["hooks"]
664+
effective_root = sm.deploy_root or t.root_dir
665+
hook_prefixes.append(f"{effective_root}/hooks/")
666+
hook_prefix_tuple = tuple(hook_prefixes)
667+
636668
if managed_files is not None:
637-
# Manifest-based removal -- only remove tracked files
669+
# Manifest-based removal -- only remove tracked files
638670
deleted: list = []
639671
for rel_path in managed_files:
640-
# Normalize path separators for cross-platform compatibility
641672
normalized = rel_path.replace("\\", "/")
642-
# Only handle hook-related paths
643-
is_hook = (
644-
normalized.startswith(".github/hooks/")
645-
or normalized.startswith(".claude/hooks/")
646-
or normalized.startswith(".cursor/hooks/")
647-
or normalized.startswith(".codex/hooks/")
648-
)
649-
if not is_hook or ".." in rel_path:
673+
if not normalized.startswith(hook_prefix_tuple):
674+
continue
675+
if ".." in rel_path:
650676
continue
651-
target = project_root / rel_path
652-
if target.exists() and target.is_file():
677+
target_file = project_root / rel_path
678+
if target_file.exists() and target_file.is_file():
653679
try:
654-
target.unlink()
680+
target_file.unlink()
655681
stats['files_removed'] += 1
656-
deleted.append(target)
682+
deleted.append(target_file)
657683
except Exception:
658684
stats['errors'] += 1
659-
# Batch parent cleanup -- single bottom-up pass
685+
# Batch parent cleanup -- single bottom-up pass
660686
self.cleanup_empty_parents(deleted, stop_at=project_root)
661687
else:
662688
# Legacy fallback -- glob for old -apm suffix files
@@ -669,48 +695,45 @@ def sync_integration(self, apm_package, project_root: Path,
669695
except Exception:
670696
stats['errors'] += 1
671697

672-
# Clean APM entries from .claude/settings.json (safe -- uses _apm_source marker)
673-
settings_path = project_root / ".claude" / "settings.json"
674-
if settings_path.exists():
675-
try:
676-
with open(settings_path, 'r', encoding='utf-8') as f:
677-
settings = json.load(f)
678-
679-
if "hooks" in settings:
680-
modified = False
681-
for event_name in list(settings["hooks"].keys()):
682-
matchers = settings["hooks"][event_name]
683-
if isinstance(matchers, list):
684-
filtered = [
685-
m for m in matchers
686-
if not (isinstance(m, dict) and "_apm_source" in m)
687-
]
688-
if len(filtered) != len(matchers):
689-
modified = True
690-
settings["hooks"][event_name] = filtered
691-
if not filtered:
692-
del settings["hooks"][event_name]
693-
694-
if not settings["hooks"]:
695-
del settings["hooks"]
696-
697-
if modified:
698-
with open(settings_path, 'w', encoding='utf-8') as f:
699-
json.dump(settings, f, indent=2)
700-
f.write('\n')
701-
stats['files_removed'] += 1
702-
except (json.JSONDecodeError, OSError):
703-
stats['errors'] += 1
704-
705-
# Clean APM entries from .cursor/hooks.json (safe -- uses _apm_source marker)
706-
self._clean_apm_entries_from_json(
707-
project_root / ".cursor" / "hooks.json", stats,
708-
)
709-
710-
# Clean APM entries from .codex/hooks.json (safe -- uses _apm_source marker)
711-
self._clean_apm_entries_from_json(
712-
project_root / ".codex" / "hooks.json", stats,
713-
)
698+
# Clean APM entries from merged-hook JSON configs (uses _apm_source marker)
699+
for t in source:
700+
config = _MERGE_HOOK_TARGETS.get(t.name)
701+
if config is not None:
702+
json_path = project_root / t.root_dir / config.config_filename
703+
if t.name == "claude":
704+
# Claude uses settings.json with special structure
705+
if json_path.exists():
706+
try:
707+
with open(json_path, 'r', encoding='utf-8') as f:
708+
settings = json.load(f)
709+
710+
if "hooks" in settings:
711+
modified = False
712+
for event_name in list(settings["hooks"].keys()):
713+
matchers = settings["hooks"][event_name]
714+
if isinstance(matchers, list):
715+
filtered = [
716+
m for m in matchers
717+
if not (isinstance(m, dict) and "_apm_source" in m)
718+
]
719+
if len(filtered) != len(matchers):
720+
modified = True
721+
settings["hooks"][event_name] = filtered
722+
if not filtered:
723+
del settings["hooks"][event_name]
724+
725+
if not settings["hooks"]:
726+
del settings["hooks"]
727+
728+
if modified:
729+
with open(json_path, 'w', encoding='utf-8') as f:
730+
json.dump(settings, f, indent=2)
731+
f.write('\n')
732+
stats['files_removed'] += 1
733+
except (json.JSONDecodeError, OSError):
734+
stats['errors'] += 1
735+
else:
736+
self._clean_apm_entries_from_json(json_path, stats)
714737

715738
return stats
716739

0 commit comments

Comments
 (0)