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