diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8dd27229..4708cb45b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,11 +11,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Fixed
 
 - Hook integrator now processes the `windows` property in hook JSON files, copying referenced scripts and rewriting paths during install/compile (#311)
+- `apm install -g` now correctly deploys to user-scope directories (e.g., `~/.copilot/` instead of `~/.github/`) across all integrators and uninstall paths (#542)
+- `apm install -g` no longer deploys instructions to `~/.copilot/instructions/` (unsupported by Copilot CLI) (#542)
+- Fixed partition routing for multi-level user directories (e.g., `~/.config/opencode/`) (#542)
+- Fixed uninstall re-integration deploying to wrong paths at user scope (#542)
 
 ### Added
 
 - `apm install` now deploys `.instructions.md` files to `.claude/rules/*.md` for Claude Code, converting `applyTo:` frontmatter to Claude's `paths:` format (#516)
 
+### Changed
+
+- Scope resolution now happens once via `TargetProfile.for_scope()` and `resolve_targets()` -- integrators no longer need scope-aware parameters (#542)
+
 ## [0.8.9] - 2026-03-31
 
 ### Fixed
diff --git a/docs/src/content/docs/guides/dependencies.md b/docs/src/content/docs/guides/dependencies.md
index f8ba98006..b0c0778c9 100644
--- a/docs/src/content/docs/guides/dependencies.md
+++ b/docs/src/content/docs/guides/dependencies.md
@@ -310,7 +310,7 @@ Coverage varies by target and primitive type:
 | Target | Status | User-level dir | Primitives | Not supported |
 |--------|--------|---------------|------------|---------------|
 | Claude Code | Supported | `~/.claude/` | Skills, agents, commands, hooks, instructions | -- |
-| Copilot CLI | Partial | `~/.copilot/` | Skills, agents, instructions, hooks | Prompts (not supported by Copilot CLI) |
+| Copilot CLI | Partial | `~/.copilot/` | Skills, agents, hooks | Prompts, instructions |
 | Cursor | Partial | `~/.cursor/` | Skills, agents, hooks | Rules |
 | OpenCode | Partial | `~/.config/opencode/` | Skills, agents, commands | Hooks |
 
diff --git a/src/apm_cli/commands/install.py b/src/apm_cli/commands/install.py
index 409b15c09..4eafbce5e 100644
--- a/src/apm_cli/commands/install.py
+++ b/src/apm_cli/commands/install.py
@@ -1011,27 +1011,10 @@ def _log_integration(msg):
 
     # --- target x primitive dispatch loop ---
     for _target in targets:
-        # Skip entire target when user scope is not supported
-        if _user_scope and not _target.user_supported:
-            if logger:
-                logger.verbose_detail(
-                    f"Skipping {_target.name} at user scope (not supported)"
-                )
-            continue
-
         for _prim_name, _mapping in _target.primitives.items():
             if _prim_name == "skills":
                 continue  # handled separately below
 
-            # Skip primitives unsupported at user scope
-            if _user_scope and _prim_name in _target.unsupported_user_primitives:
-                if logger:
-                    logger.verbose_detail(
-                        f"Skipping {_prim_name} for {_target.name} "
-                        f"at user scope (not supported)"
-                    )
-                continue
-
             # --- hooks (different return type) ---
             if _prim_name == "hooks":
                 hook_result = hook_integrator.integrate_hooks_for_target(
@@ -1331,7 +1314,7 @@ def download_callback(dep_ref, modules_dir, parent_chain=""):
     )
 
     try:
-        dependency_graph = resolver.resolve_dependencies(project_root)
+        dependency_graph = resolver.resolve_dependencies(apm_dir)
 
         # Verbose: show resolved tree summary
         if logger:
@@ -1438,22 +1421,18 @@ def _collect_descendants(node, visited=None):
         # Determine active targets.  When --target or apm.yml target is set
         # the user's choice wins.  Otherwise auto-detect from existing dirs,
         # falling back to copilot when nothing is found.
-        from apm_cli.integration.targets import (
-            active_targets as _active_targets,
-            active_targets_user_scope as _active_targets_user,
-        )
+        from apm_cli.integration.targets import resolve_targets as _resolve_targets
 
         _is_user = scope is InstallScope.USER
-        if _is_user:
-            _targets = _active_targets_user(explicit_target=_explicit)
-        else:
-            _targets = _active_targets(project_root, explicit_target=_explicit)
+        _targets = _resolve_targets(
+            project_root, user_scope=_is_user, explicit_target=_explicit,
+        )
 
         # Log target detection results
         if logger and _targets:
             _scope_label = "global" if _is_user else "project"
             _target_names = ", ".join(
-                f"{t.name} (~/{t.effective_root(user_scope=_is_user)}/)"
+                f"{t.name} (~/{t.root_dir}/)"
                 if _is_user else t.name
                 for t in _targets
             )
@@ -1467,7 +1446,7 @@ def _collect_descendants(node, visited=None):
                 )
 
         for _t in _targets:
-            _root = _t.effective_root(user_scope=_is_user)
+            _root = _t.root_dir
             _target_dir = project_root / _root
             if not _target_dir.exists():
                 _target_dir.mkdir(parents=True, exist_ok=True)
@@ -2357,8 +2336,10 @@ def _collect_descendants(node, visited=None):
             _deleted_orphan_paths: builtins.list = []
             for _orphan_path in sorted(orphaned_deployed_files):
                 # validate_deploy_path() is the safety gate: it rejects path-traversal,
-                # requires .github/ or .claude/ prefix, and checks the resolved path
-                # stays within project_root — so rmtree is safe here.
+                # requires a known integration prefix, and checks the resolved path
+                # stays within project_root -- so rmtree is safe here.
+                # Use default prefixes (all KNOWN_TARGETS) so legacy deployed
+                # paths from older buggy installs are also cleaned up.
                 if BaseIntegrator.validate_deploy_path(_orphan_path, project_root):
                     _target = project_root / _orphan_path
                     if _target.exists():
diff --git a/src/apm_cli/commands/uninstall/cli.py b/src/apm_cli/commands/uninstall/cli.py
index b49b6ecc0..8bd453c44 100644
--- a/src/apm_cli/commands/uninstall/cli.py
+++ b/src/apm_cli/commands/uninstall/cli.py
@@ -179,7 +179,10 @@ def uninstall(ctx, packages, dry_run, verbose, global_):
         cleaned = {"prompts": 0, "agents": 0, "skills": 0, "commands": 0, "hooks": 0, "instructions": 0}
         try:
             apm_package = APMPackage.from_apm_yml(manifest_path)
-            cleaned = _sync_integrations_after_uninstall(apm_package, deploy_root, all_deployed_files, logger)
+            cleaned = _sync_integrations_after_uninstall(
+                apm_package, deploy_root, all_deployed_files, logger,
+                user_scope=scope is InstallScope.USER,
+            )
         except Exception:
             pass  # Best effort cleanup
 
diff --git a/src/apm_cli/commands/uninstall/engine.py b/src/apm_cli/commands/uninstall/engine.py
index 27a37ea8e..40cf69a79 100644
--- a/src/apm_cli/commands/uninstall/engine.py
+++ b/src/apm_cli/commands/uninstall/engine.py
@@ -224,8 +224,12 @@ def _cleanup_transitive_orphans(lockfile, packages_to_remove, apm_modules_dir, a
     return removed, actual_orphans
 
 
-def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_files, logger):
-    """Remove deployed files and re-integrate from remaining packages."""
+def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_files, logger, user_scope=False):
+    """Remove deployed files and re-integrate from remaining packages.
+
+    When *user_scope* is ``True``, targets are resolved for user-level
+    deployment so cleanup and re-integration use the correct paths.
+    """
     from ...integration.base_integrator import BaseIntegrator
     from ...models.apm_package import PackageInfo, validate_apm_package
     from ...integration.prompt_integrator import PromptIntegrator
@@ -234,10 +238,19 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
     from ...integration.command_integrator import CommandIntegrator
     from ...integration.hook_integrator import HookIntegrator
     from ...integration.instruction_integrator import InstructionIntegrator
-    from ...integration.targets import KNOWN_TARGETS, active_targets
+    from ...integration.targets import resolve_targets
+
+    # Resolve targets once -- used for both Phase 1 removal and Phase 2 re-integration.
+    config_target = apm_package.target
+    _explicit = config_target or None
+    _resolved_targets = resolve_targets(project_root, user_scope=user_scope, explicit_target=_explicit)
 
     sync_managed = all_deployed_files if all_deployed_files else None
     if sync_managed is not None:
+        # For removal, partition against both resolved AND unresolved targets
+        # so legacy deployed_files (from older buggy user-scope installs that
+        # wrote to .github/ instead of .copilot/) are still bucketed and
+        # cleaned up during uninstall.
         _buckets = BaseIntegrator.partition_managed_files(sync_managed)
     else:
         _buckets = None
@@ -258,7 +271,7 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
         "instructions": (_instr_int, "instructions"),
     }
 
-    for _target in KNOWN_TARGETS.values():
+    for _target in _resolved_targets:
         for _prim_name, _mapping in _target.primitives.items():
             if _prim_name in ("skills", "hooks"):
                 continue
@@ -285,7 +298,7 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
     # Skills (multi-target, handled by SkillIntegrator)
     # Check both target root_dir and deploy_root for skill directories
     _skill_dirs_exist = False
-    for t in KNOWN_TARGETS.values():
+    for t in _resolved_targets:
         if t.supports("skills"):
             sm = t.primitives["skills"]
             er = sm.deploy_root or t.root_dir
@@ -295,7 +308,8 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
     if _skill_dirs_exist:
         integrator = SkillIntegrator()
         result = integrator.sync_integration(apm_package, project_root,
-                                             managed_files=_buckets["skills"] if _buckets else None)
+                                             managed_files=_buckets["skills"] if _buckets else None,
+                                             targets=_resolved_targets)
         counts["skills"] = result.get("files_removed", 0)
 
     # Hooks (multi-target, sync_integration handles all targets)
@@ -306,9 +320,7 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
 
 
     # Phase 2: Re-integrate from remaining installed packages
-    config_target = apm_package.target
-    _explicit = config_target or None
-    _targets = active_targets(project_root, explicit_target=_explicit)
+    _targets = _resolved_targets
 
     prompt_integrator = PromptIntegrator()
     agent_integrator = AgentIntegrator()
@@ -358,7 +370,7 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
                         getattr(_integrator, _method)(
                             _target, pkg_info, project_root,
                         )
-            skill_integrator.integrate_package_skill(pkg_info, project_root)
+            skill_integrator.integrate_package_skill(pkg_info, project_root, targets=_targets)
         except Exception:
             pkg_id = dep_ref.get_identity() if hasattr(dep_ref, "get_identity") else str(dep_ref)
             logger.warning(f"Best-effort re-integration skipped for {pkg_id}")
diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py
index e85b59022..549492f86 100644
--- a/src/apm_cli/integration/base_integrator.py
+++ b/src/apm_cli/integration/base_integrator.py
@@ -96,28 +96,33 @@ def normalize_managed_files(managed_files: Optional[Set[str]]) -> Optional[Set[s
     # Known integration prefixes that APM is allowed to deploy/remove under.
     # Derived from ``targets.KNOWN_TARGETS`` so adding a target auto-propagates.
     @staticmethod
-    def _get_integration_prefixes() -> tuple:
+    def _get_integration_prefixes(targets=None) -> tuple:
         from apm_cli.integration.targets import get_integration_prefixes
-        return get_integration_prefixes()
+        return get_integration_prefixes(targets=targets)
 
     @staticmethod
     def validate_deploy_path(
         rel_path: str,
         project_root: Path,
         allowed_prefixes: tuple | None = None,
+        targets=None,
     ) -> bool:
         """Return True if *rel_path* is safe for APM to deploy or remove.
 
         Centralised security gate for all paths read from ``deployed_files``
         before any filesystem operation.
 
+        When *targets* is provided, allowed prefixes are derived from
+        those (scope-resolved) profiles.  Otherwise uses all known
+        target prefixes.
+
         Checks:
         1. No path-traversal components (``..``)
         2. Starts with an allowed integration prefix
         3. Resolves within *project_root*
         """
         if allowed_prefixes is None:
-            allowed_prefixes = BaseIntegrator._get_integration_prefixes()
+            allowed_prefixes = BaseIntegrator._get_integration_prefixes(targets=targets)
         if ".." in rel_path:
             return False
         if not rel_path.startswith(allowed_prefixes):
@@ -157,33 +162,38 @@ def partition_bucket_key(prim_name: str, target_name: str) -> str:
     @staticmethod
     def partition_managed_files(
         managed_files: Set[str],
+        targets=None,
     ) -> dict:
         """Partition *managed_files* by integration prefix in a single pass.
 
-        Bucket keys are generated dynamically from ``KNOWN_TARGETS`` so
-        adding a new target or primitive automatically creates the
-        corresponding bucket.
+        When *targets* is provided, prefixes and bucket keys are derived
+        from those (scope-resolved) profiles.  Otherwise falls back to
+        ``KNOWN_TARGETS`` for backward compatibility.
+
+        Bucket keys are generated dynamically so adding a new target or
+        primitive automatically creates the corresponding bucket.
 
         Cross-target buckets (``skills``, ``hooks``) group all targets
         together because ``SkillIntegrator`` and ``HookIntegrator``
         handle multi-target sync internally.
 
-        Path routing uses an O(1) dict keyed by ``(root_dir, subdir)``
-        parsed from the first two path segments, avoiding a linear scan
-        over all known prefixes.
+        Path routing uses a longest-prefix-match strategy so multi-level
+        roots like ``.config/opencode/`` are handled correctly.
         """
         from apm_cli.integration.targets import KNOWN_TARGETS
 
+        source = targets if targets is not None else KNOWN_TARGETS.values()
+
         buckets: dict = {}
 
         # Skills and hooks are cross-target (single bucket each)
         skill_prefixes: list = []
         hook_prefixes: list = []
 
-        # O(1) lookup: (root_dir, subdir) -> bucket_key
-        component_map: dict = {}
+        # prefix -> bucket_key (longest-prefix-match routing)
+        prefix_map: dict = {}
 
-        for target in KNOWN_TARGETS.values():
+        for target in source:
             for prim_name, mapping in target.primitives.items():
                 effective_root = mapping.deploy_root or target.root_dir
                 prefix = f"{effective_root}/{mapping.subdir}/" if mapping.subdir else f"{effective_root}/"
@@ -198,9 +208,7 @@ def partition_managed_files(
                     )
                     if bucket_key not in buckets:
                         buckets[bucket_key] = set()
-                    component_map[
-                        (effective_root, mapping.subdir)
-                    ] = bucket_key
+                    prefix_map[prefix] = bucket_key
 
         buckets["skills"] = set()
         buckets["hooks"] = set()
@@ -208,22 +216,41 @@ def partition_managed_files(
         skill_tuple = tuple(skill_prefixes)
         hook_tuple = tuple(hook_prefixes)
 
-        # Single O(M) pass -- each path is routed in O(1)
-        # Component_map is checked first: it holds specific (root, subdir)
-        # pairs and takes priority over broad prefix matching.  This prevents
-        # catch-all hook prefixes (e.g. ".codex/") from swallowing paths
-        # that belong to a more specific bucket (e.g. ".codex/agents/").
+        # Build a prefix trie keyed by path segments for O(depth) routing.
+        # Each node is a dict; the special key "_bucket" stores the bucket
+        # for a complete prefix ending at that node.  This preserves the
+        # "single pass, O(1) per path" property from the original
+        # component_map approach while supporting multi-level roots like
+        # .config/opencode/.
+        trie: dict = {}
+        for prefix, bucket_key in prefix_map.items():
+            segments = [s for s in prefix.split("/") if s]
+            node = trie
+            for segment in segments:
+                child = node.get(segment)
+                if child is None:
+                    child = {}
+                    node[segment] = child
+                node = child
+            node["_bucket"] = bucket_key
+
         for p in managed_files:
-            slash1 = p.find("/")
-            if slash1 > 0:
-                slash2 = p.find("/", slash1 + 1)
-                if slash2 > 0:
-                    bkey = component_map.get(
-                        (p[:slash1], p[slash1 + 1 : slash2])
-                    )
-                    if bkey:
-                        buckets[bkey].add(p)
-                        continue
+            # Walk the trie; keep the deepest bucket match (longest prefix).
+            segments = [s for s in p.split("/") if s]
+            node = trie
+            last_bucket: str | None = None
+            for segment in segments:
+                child = node.get(segment)
+                if child is None:
+                    break
+                node = child
+                bk = node.get("_bucket")
+                if bk is not None:
+                    last_bucket = bk
+            if last_bucket is not None:
+                buckets[last_bucket].add(p)
+                continue
+            # Fall back to cross-target buckets
             if p.startswith(skill_tuple):
                 buckets["skills"].add(p)
             elif p.startswith(hook_tuple):
diff --git a/src/apm_cli/integration/skill_integrator.py b/src/apm_cli/integration/skill_integrator.py
index d09664fc3..9a6f9ffad 100644
--- a/src/apm_cli/integration/skill_integrator.py
+++ b/src/apm_cli/integration/skill_integrator.py
@@ -301,6 +301,9 @@ def copy_skill_to_target(
     deployed: list[Path] = []
 
     # Deploy to all active targets that support skills.
+    # When no targets are provided, fall back to project-scope detection.
+    # Callers responsible for user-scope should pass resolved targets
+    # from resolve_targets().
     if targets is None:
         from apm_cli.integration.targets import active_targets
         targets = active_targets(target_base)
@@ -309,6 +312,12 @@ def copy_skill_to_target(
             continue
         skills_mapping = target.primitives["skills"]
         effective_root = skills_mapping.deploy_root or target.root_dir
+
+        # Skip if target dir does not exist and auto_create is disabled
+        target_root_dir = target_base / target.root_dir
+        if not target.auto_create and not target_root_dir.is_dir():
+            continue
+
         skill_dir = target_base / effective_root / "skills" / skill_name
         skill_dir.parent.mkdir(parents=True, exist_ok=True)
         if skill_dir.exists():
@@ -845,35 +854,51 @@ def integrate_package_skill(self, package_info, project_root: Path, diagnostics=
         )
     
     def sync_integration(self, apm_package, project_root: Path,
-                          managed_files: set = None) -> Dict[str, int]:
-        """Sync .github/skills/, .claude/skills/, and .cursor/skills/ with currently installed packages.
-        
-        When *managed_files* is provided, only removes skill directories whose
-        paths appear in the set.  Otherwise falls back to npm-style orphan
-        detection (derives expected names from installed dependencies).
-        
+                          managed_files: set = None, targets=None) -> Dict[str, int]:
+        """Sync skill directories with currently installed packages.
+
+        Derives skill prefixes dynamically from *targets* (or
+        ``KNOWN_TARGETS``) so user-scope paths like ``.copilot/skills/``
+        and ``.config/opencode/skills/`` are handled correctly.
+
+        When *managed_files* is provided, only removes skill directories
+        whose paths appear in the set.  Otherwise falls back to
+        npm-style orphan detection (derives expected names from installed
+        dependencies).
+
         Args:
             apm_package: APMPackage with current dependencies
             project_root: Root directory of the project
             managed_files: Set of relative paths known to be APM-managed
-            
+            targets: Optional list of (scope-resolved) TargetProfile objects.
+                     When ``None``, uses ``KNOWN_TARGETS``.
+
         Returns:
             Dict with cleanup statistics
         """
+        from apm_cli.integration.targets import KNOWN_TARGETS
+
+        source = targets if targets is not None else list(KNOWN_TARGETS.values())
+
         stats = {'files_removed': 0, 'errors': 0}
 
+        # Build the set of valid skill prefixes from targets
+        skill_prefixes: list[str] = []
+        for t in source:
+            if not t.supports("skills"):
+                continue
+            sm = t.primitives["skills"]
+            effective_root = sm.deploy_root or t.root_dir
+            skill_prefixes.append(f"{effective_root}/skills/")
+        skill_prefix_tuple = tuple(skill_prefixes)
+
         if managed_files is not None:
-            # Manifest-based removal  -- only remove tracked skill directories
+            # Manifest-based removal -- only remove tracked skill directories
             project_root_resolved = project_root.resolve()
             for rel_path in managed_files:
-                is_skill = (
-                    rel_path.startswith(".github/skills/")
-                    or rel_path.startswith(".claude/skills/")
-                    or rel_path.startswith(".cursor/skills/")
-                    or rel_path.startswith(".opencode/skills/")
-                    or rel_path.startswith(".agents/skills/")
-                )
-                if not is_skill or ".." in rel_path:
+                if not rel_path.startswith(skill_prefix_tuple):
+                    continue
+                if ".." in rel_path:
                     continue
                 target = project_root / rel_path
                 if not str(target.resolve()).startswith(str(project_root_resolved)):
@@ -885,7 +910,7 @@ def sync_integration(self, apm_package, project_root: Path,
                     except Exception:
                         stats['errors'] += 1
             return stats
-        
+
         # Legacy fallback: npm-style orphan detection
         # Build set of expected skill directory names from installed packages
         installed_skill_names = set()
@@ -893,10 +918,10 @@ def sync_integration(self, apm_package, project_root: Path,
             raw_name = dep.repo_url.split('/')[-1]
             if dep.is_virtual and dep.virtual_path:
                 raw_name = dep.virtual_path.split('/')[-1]
-            is_valid, _ = validate_skill_name(raw_name)
+            is_valid, error_msg = validate_skill_name(raw_name)
             skill_name = raw_name if is_valid else normalize_skill_name(raw_name)
             installed_skill_names.add(skill_name)
-            
+
             # Also include promoted sub-skills from installed packages
             install_path = dep.get_install_path(project_root / "apm_modules")
             sub_skills_dir = install_path / ".apm" / "skills"
@@ -906,45 +931,26 @@ def sync_integration(self, apm_package, project_root: Path,
                         raw_sub = sub_skill_path.name
                         is_valid, _ = validate_skill_name(raw_sub)
                         installed_skill_names.add(raw_sub if is_valid else normalize_skill_name(raw_sub))
-        
-        # Clean .github/skills/ (primary)
-        github_skills_dir = project_root / ".github" / "skills"
-        if github_skills_dir.exists():
-            result = self._clean_orphaned_skills(github_skills_dir, installed_skill_names)
-            stats['files_removed'] += result['files_removed']
-            stats['errors'] += result['errors']
-        
-        # Clean .claude/skills/ (secondary - T7 compatibility)
-        claude_skills_dir = project_root / ".claude" / "skills"
-        if claude_skills_dir.exists():
-            result = self._clean_orphaned_skills(claude_skills_dir, installed_skill_names)
-            stats['files_removed'] += result['files_removed']
-            stats['errors'] += result['errors']
-        
-        # Clean .cursor/skills/ (tertiary - compatibility)
-        cursor_skills_dir = project_root / ".cursor" / "skills"
-        if cursor_skills_dir.exists():
-            result = self._clean_orphaned_skills(cursor_skills_dir, installed_skill_names)
-            stats['files_removed'] += result['files_removed']
-            stats['errors'] += result['errors']
-        
-        # Clean .opencode/skills/
-        opencode_skills_dir = project_root / ".opencode" / "skills"
-        if opencode_skills_dir.exists():
-            result = self._clean_orphaned_skills(opencode_skills_dir, installed_skill_names)
-            stats['files_removed'] += result['files_removed']
-            stats['errors'] += result['errors']
-        
-        # Clean .agents/skills/ (cross-tool agent skills standard, used by Codex)
-        # Only clean if .codex/ exists -- .agents/ is cross-tool, so we must
-        # not delete skills managed by other tools when Codex is not active.
-        codex_dir = project_root / ".codex"
-        agents_skills_dir = project_root / ".agents" / "skills"
-        if codex_dir.exists() and agents_skills_dir.exists():
-            result = self._clean_orphaned_skills(agents_skills_dir, installed_skill_names)
-            stats['files_removed'] += result['files_removed']
-            stats['errors'] += result['errors']
-        
+
+        # Clean all target skill directories dynamically
+        for t in source:
+            if not t.supports("skills"):
+                continue
+            sm = t.primitives["skills"]
+            effective_root = sm.deploy_root or t.root_dir
+
+            # Special guard for cross-tool deploy_root (.agents/)
+            # Only clean if the owning target dir exists
+            if sm.deploy_root:
+                if not (project_root / t.root_dir).is_dir():
+                    continue
+
+            skills_dir = project_root / effective_root / "skills"
+            if skills_dir.exists():
+                result = self._clean_orphaned_skills(skills_dir, installed_skill_names)
+                stats['files_removed'] += result['files_removed']
+                stats['errors'] += result['errors']
+
         return stats
     
     def _clean_orphaned_skills(self, skills_dir: Path, installed_skill_names: set) -> Dict[str, int]:
diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py
index e8a492f7d..a00aae034 100644
--- a/src/apm_cli/integration/targets.py
+++ b/src/apm_cli/integration/targets.py
@@ -114,6 +114,39 @@ def supports_at_user_scope(self, primitive: str) -> bool:
             return False
         return primitive in self.primitives
 
+    def for_scope(self, user_scope: bool = False) -> "TargetProfile | None":
+        """Return a scope-resolved copy of this profile.
+
+        When *user_scope* is ``False``, returns ``self`` unchanged.
+
+        When *user_scope* is ``True``:
+        - Returns ``None`` if this target does not support user scope.
+        - Otherwise returns a frozen copy with ``root_dir`` set to
+          ``user_root_dir`` (or left unchanged when ``user_root_dir``
+          is ``None``) and ``primitives`` filtered to exclude entries
+          listed in ``unsupported_user_primitives``.
+
+        This is the **single place** where scope resolution happens.
+        All downstream code reads ``target.root_dir`` directly.
+        """
+        if not user_scope:
+            return self
+        if not self.user_supported:
+            return None
+
+        from dataclasses import replace
+
+        new_root = self.user_root_dir or self.root_dir
+        if self.unsupported_user_primitives:
+            filtered = {
+                k: v for k, v in self.primitives.items()
+                if k not in self.unsupported_user_primitives
+            }
+        else:
+            filtered = self.primitives
+
+        return replace(self, root_dir=new_root, primitives=filtered)
+
 
 # ------------------------------------------------------------------
 # Known targets
@@ -121,7 +154,7 @@ def supports_at_user_scope(self, primitive: str) -> bool:
 
 KNOWN_TARGETS: Dict[str, TargetProfile] = {
     # Copilot (GitHub) -- at user scope, Copilot CLI reads ~/.copilot/
-    # instead of ~/.github/.  Prompts are not supported at user scope.
+    # instead of ~/.github/.  Prompts and instructions are not supported at user scope.
     # Ref: https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/create-custom-agents-for-cli
     "copilot": TargetProfile(
         name="copilot",
@@ -147,7 +180,7 @@ def supports_at_user_scope(self, primitive: str) -> bool:
         detect_by_dir=True,
         user_supported="partial",
         user_root_dir=".copilot",
-        unsupported_user_primitives=("prompts",),
+        unsupported_user_primitives=("prompts", "instructions"),
     ),
     # Claude Code -- ~/.claude/ is the documented user-level config directory.
     # All primitives are supported at user scope.
@@ -251,18 +284,23 @@ def supports_at_user_scope(self, primitive: str) -> bool:
 }
 
 
-def get_integration_prefixes() -> tuple:
+def get_integration_prefixes(targets=None) -> tuple:
     """Return all known target root prefixes as a tuple.
 
     Used by ``BaseIntegrator.validate_deploy_path`` so the allow-list
     stays in sync with registered targets.
 
+    When *targets* is provided, prefixes are derived from those
+    (already scope-resolved) profiles.  Otherwise falls back to
+    ``KNOWN_TARGETS`` for backward compatibility.
+
     Includes prefixes from ``deploy_root`` overrides (e.g. ``.agents/``
     for Codex skills) so cross-root paths pass security validation.
     """
+    source = targets if targets is not None else KNOWN_TARGETS.values()
     prefixes: list[str] = []
     seen: set[str] = set()
-    for t in KNOWN_TARGETS.values():
+    for t in source:
         if t.prefix not in seen:
             seen.add(t.prefix)
             prefixes.append(t.prefix)
@@ -367,3 +405,35 @@ def active_targets(project_root, explicit_target: "Optional[str]" = None) -> lis
 
     # --- fallback: copilot is the universal default ---
     return [KNOWN_TARGETS["copilot"]]
+
+
+def resolve_targets(
+    project_root,
+    user_scope: bool = False,
+    explicit_target: "Optional[str]" = None,
+) -> list:
+    """Return scope-resolved ``TargetProfile`` instances.
+
+    This is the **single entry point** for obtaining deployment targets.
+    It combines target detection (or explicit selection), scope resolution
+    (``for_scope``), and primitive filtering into one call.
+
+    Callers receive profiles where ``root_dir`` is already correct for
+    the requested scope -- no ``effective_root()`` calls needed.
+
+    Args:
+        project_root: Workspace root (``Path.cwd()`` or ``Path.home()``).
+        user_scope: When ``True``, resolve for user-level deployment.
+        explicit_target: Canonical target name or ``"all"``.
+    """
+    if user_scope:
+        raw = active_targets_user_scope(explicit_target)
+    else:
+        raw = active_targets(project_root, explicit_target)
+
+    resolved = []
+    for t in raw:
+        scoped = t.for_scope(user_scope=user_scope)
+        if scoped is not None:
+            resolved.append(scoped)
+    return resolved
diff --git a/tests/unit/core/test_scope.py b/tests/unit/core/test_scope.py
index 3c978b5c3..811457238 100644
--- a/tests/unit/core/test_scope.py
+++ b/tests/unit/core/test_scope.py
@@ -192,6 +192,7 @@ def test_claude_uses_default_root_at_user_scope(self):
 
     def test_copilot_unsupported_user_primitives(self):
         assert "prompts" in KNOWN_TARGETS["copilot"].unsupported_user_primitives
+        assert "instructions" in KNOWN_TARGETS["copilot"].unsupported_user_primitives
 
     def test_effective_root_project_scope(self):
         assert KNOWN_TARGETS["copilot"].effective_root(user_scope=False) == ".github"
@@ -207,9 +208,10 @@ def test_supports_at_user_scope_true(self):
         assert KNOWN_TARGETS["claude"].supports_at_user_scope("commands") is True
 
     def test_supports_at_user_scope_partial(self):
-        # Copilot supports agents at user scope but not prompts
+        # Copilot supports agents at user scope but not prompts or instructions
         assert KNOWN_TARGETS["copilot"].supports_at_user_scope("agents") is True
         assert KNOWN_TARGETS["copilot"].supports_at_user_scope("prompts") is False
+        assert KNOWN_TARGETS["copilot"].supports_at_user_scope("instructions") is False
 
     def test_supports_at_user_scope_cursor_partial(self):
         # Cursor supports agents at user scope but not instructions
@@ -264,8 +266,8 @@ def test_warn_message_includes_partial_targets(self):
 
     def test_warn_message_includes_unsupported_primitives(self):
         msg = warn_unsupported_user_scope()
-        # Copilot excludes prompts
-        assert "copilot (prompts)" in msg
+        # Copilot excludes prompts and instructions
+        assert "copilot (prompts, instructions)" in msg
         # Cursor excludes instructions
         assert "cursor (instructions)" in msg
         # OpenCode excludes hooks
diff --git a/tests/unit/integration/test_data_driven_dispatch.py b/tests/unit/integration/test_data_driven_dispatch.py
index 5c65e6689..f56ccda1b 100644
--- a/tests/unit/integration/test_data_driven_dispatch.py
+++ b/tests/unit/integration/test_data_driven_dispatch.py
@@ -539,3 +539,227 @@ def test_deploy_root_validation(self):
         root = Path("/fake/project")
         assert BaseIntegrator.validate_deploy_path(".agents/skills/my-skill/SKILL.md", root)
         assert BaseIntegrator.validate_deploy_path(".codex/agents/my-agent.toml", root)
+
+
+# ===================================================================
+# 8. TestGetIntegrationPrefixesTargetsParam
+# ===================================================================
+
+
+class TestGetIntegrationPrefixesTargetsParam:
+    """Verify get_integration_prefixes with explicit targets parameter."""
+
+    def test_prefixes_from_resolved_copilot(self):
+        """Resolved copilot target yields .copilot/ prefix."""
+        from dataclasses import replace
+        from apm_cli.integration.targets import get_integration_prefixes
+
+        resolved = replace(KNOWN_TARGETS["copilot"], root_dir=".copilot")
+        prefixes = get_integration_prefixes(targets=[resolved])
+        assert ".copilot/" in prefixes
+        # Should NOT include .github/ since we only passed resolved copilot
+        assert ".github/" not in prefixes
+
+    def test_prefixes_backward_compat(self):
+        """No targets param returns default KNOWN_TARGETS prefixes."""
+        from apm_cli.integration.targets import get_integration_prefixes
+
+        prefixes = get_integration_prefixes()
+        assert ".github/" in prefixes
+        assert ".claude/" in prefixes
+
+
+# ===================================================================
+# 9. TestScopeResolvedPartition
+# ===================================================================
+
+
+class TestScopeResolvedPartition:
+    """Verify partition_managed_files and validate_deploy_path with
+    scope-resolved targets."""
+
+    def test_partition_with_user_scope_copilot_targets(self):
+        """Partition routes .copilot/ paths when given resolved targets."""
+        from dataclasses import replace
+
+        copilot = KNOWN_TARGETS["copilot"]
+        resolved = replace(copilot, root_dir=".copilot")
+        managed = {
+            ".copilot/agents/my-agent.md",
+            ".copilot/skills/my-skill/SKILL.md",
+        }
+        buckets = BaseIntegrator.partition_managed_files(managed, targets=[resolved])
+        assert ".copilot/agents/my-agent.md" in buckets.get("agents_github", set())
+        assert ".copilot/skills/my-skill/SKILL.md" in buckets.get("skills", set())
+
+    def test_partition_with_opencode_user_scope(self):
+        """Partition routes .config/opencode/ paths correctly."""
+        from dataclasses import replace
+
+        opencode = KNOWN_TARGETS["opencode"]
+        resolved = replace(opencode, root_dir=".config/opencode")
+        managed = {
+            ".config/opencode/agents/reviewer.md",
+            ".config/opencode/commands/test.md",
+            ".config/opencode/skills/my-skill/SKILL.md",
+        }
+        buckets = BaseIntegrator.partition_managed_files(managed, targets=[resolved])
+        assert ".config/opencode/agents/reviewer.md" in buckets.get(
+            "agents_opencode", set()
+        )
+        assert ".config/opencode/commands/test.md" in buckets.get(
+            "commands_opencode", set()
+        )
+        assert ".config/opencode/skills/my-skill/SKILL.md" in buckets.get(
+            "skills", set()
+        )
+
+    def test_partition_backward_compat_no_targets(self):
+        """Without targets param, uses KNOWN_TARGETS (existing behavior)."""
+        managed = {
+            ".github/prompts/test.prompt.md",
+            ".claude/commands/test.md",
+        }
+        buckets = BaseIntegrator.partition_managed_files(managed)
+        assert ".github/prompts/test.prompt.md" in buckets.get("prompts", set())
+        assert ".claude/commands/test.md" in buckets.get("commands", set())
+
+    def test_validate_deploy_path_with_resolved_targets(self):
+        """validate_deploy_path accepts .copilot/ with resolved targets."""
+        from dataclasses import replace
+
+        copilot = KNOWN_TARGETS["copilot"]
+        resolved = replace(copilot, root_dir=".copilot")
+        root = Path("/fake/home")
+        assert BaseIntegrator.validate_deploy_path(
+            ".copilot/agents/my-agent.md",
+            root,
+            targets=[resolved],
+        )
+
+    def test_validate_deploy_path_rejects_copilot_without_resolved(self):
+        """validate_deploy_path rejects .copilot/ without resolved targets."""
+        root = Path("/fake/home")
+        assert not BaseIntegrator.validate_deploy_path(
+            ".copilot/agents/my-agent.md",
+            root,
+        )
+
+    def test_validate_deploy_path_backward_compat(self):
+        """Default (no targets) preserves existing behavior."""
+        root = Path("/fake/project")
+        assert BaseIntegrator.validate_deploy_path(".github/prompts/test.md", root)
+        assert BaseIntegrator.validate_deploy_path(".claude/commands/test.md", root)
+        assert not BaseIntegrator.validate_deploy_path("../escape.md", root)
+
+    def test_partition_codex_still_works(self):
+        """Codex deployed_files are routed to correct buckets."""
+        managed = {
+            ".codex/agents/my-agent.toml",
+            ".agents/skills/my-skill/SKILL.md",
+            ".codex/hooks/pkg/script.sh",
+        }
+        buckets = BaseIntegrator.partition_managed_files(managed)
+        assert ".codex/agents/my-agent.toml" in buckets.get("agents_codex", set())
+        assert ".agents/skills/my-skill/SKILL.md" in buckets.get("skills", set())
+        assert ".codex/hooks/pkg/script.sh" in buckets.get("hooks", set())
+
+
+# ===================================================================
+# 8. TestForScope
+# ===================================================================
+
+
+class TestForScope:
+    """Verify TargetProfile.for_scope() and resolve_targets()."""
+
+    def test_project_scope_returns_self(self):
+        """for_scope(user_scope=False) returns the same object."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+        copilot = KNOWN_TARGETS["copilot"]
+        assert copilot.for_scope(user_scope=False) is copilot
+
+    def test_unsupported_target_returns_none(self):
+        """for_scope returns None for targets that don't support user scope."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+        codex = KNOWN_TARGETS["codex"]
+        assert codex.user_supported is False
+        assert codex.for_scope(user_scope=True) is None
+
+    def test_resolves_root_dir_to_user_root(self):
+        """for_scope replaces root_dir with user_root_dir."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+        copilot = KNOWN_TARGETS["copilot"]
+        resolved = copilot.for_scope(user_scope=True)
+        assert resolved is not None
+        assert resolved.root_dir == ".copilot"
+        assert resolved.name == "copilot"
+
+    def test_user_root_dir_none_keeps_root_dir(self):
+        """When user_root_dir is None, root_dir stays unchanged."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+        claude = KNOWN_TARGETS["claude"]
+        assert claude.user_root_dir is None
+        resolved = claude.for_scope(user_scope=True)
+        assert resolved is not None
+        assert resolved.root_dir == ".claude"
+
+    def test_filters_unsupported_primitives(self):
+        """for_scope removes unsupported primitives from the dict."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+        copilot = KNOWN_TARGETS["copilot"]
+        assert "prompts" in copilot.primitives
+        assert "instructions" in copilot.primitives
+        resolved = copilot.for_scope(user_scope=True)
+        assert "prompts" not in resolved.primitives
+        assert "instructions" not in resolved.primitives
+        # Supported primitives remain
+        assert "agents" in resolved.primitives
+        assert "skills" in resolved.primitives
+        assert "hooks" in resolved.primitives
+
+    def test_no_unsupported_primitives_keeps_all(self):
+        """Targets with empty unsupported_user_primitives keep all primitives."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+        claude = KNOWN_TARGETS["claude"]
+        assert claude.unsupported_user_primitives == ()
+        resolved = claude.for_scope(user_scope=True)
+        assert resolved.primitives == claude.primitives
+
+    def test_prefix_property_reflects_resolved_root(self):
+        """The prefix property uses the resolved root_dir."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+        copilot = KNOWN_TARGETS["copilot"]
+        resolved = copilot.for_scope(user_scope=True)
+        assert resolved.prefix == ".copilot/"
+        assert copilot.prefix == ".github/"
+
+    def test_opencode_resolves_to_config_dir(self):
+        """OpenCode resolves to .config/opencode at user scope."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+        opencode = KNOWN_TARGETS["opencode"]
+        resolved = opencode.for_scope(user_scope=True)
+        assert resolved is not None
+        assert resolved.root_dir == ".config/opencode"
+
+    def test_resolve_targets_project_scope(self):
+        """resolve_targets at project scope returns unmodified profiles."""
+        from apm_cli.integration.targets import resolve_targets
+        import tempfile
+        from pathlib import Path
+        with tempfile.TemporaryDirectory() as tmp:
+            root = Path(tmp)
+            # No target dirs exist, so fallback to copilot
+            targets = resolve_targets(root, user_scope=False)
+            assert len(targets) >= 1
+            assert targets[0].name == "copilot"
+            assert targets[0].root_dir == ".github"
+
+    def test_resolve_targets_filters_unsupported(self):
+        """resolve_targets at user scope excludes unsupported targets."""
+        from apm_cli.integration.targets import resolve_targets, KNOWN_TARGETS
+        from pathlib import Path
+        targets = resolve_targets(Path.home(), user_scope=True, explicit_target="all")
+        target_names = {t.name for t in targets}
+        # Codex doesn't support user scope
+        assert "codex" not in target_names
diff --git a/tests/unit/integration/test_skill_integrator.py b/tests/unit/integration/test_skill_integrator.py
index fbf62d603..3ac3a5548 100644
--- a/tests/unit/integration/test_skill_integrator.py
+++ b/tests/unit/integration/test_skill_integrator.py
@@ -3093,3 +3093,246 @@ def test_other_targets_still_deploy_to_own_root(self):
         assert len(deployed) == 1
         assert ".github" in str(deployed[0])
         assert (self.root / ".github" / "skills" / "my-skill" / "SKILL.md").exists()
+
+
+class TestSyncIntegrationDynamicPrefixes:
+    """Verify sync_integration derives prefixes dynamically from targets.
+
+    Issue #539: sync_integration() used hardcoded prefixes that missed
+    user-scope paths like .copilot/skills/ and .config/opencode/skills/.
+    """
+
+    def setup_method(self):
+        self.temp_dir = tempfile.mkdtemp()
+        self.project_root = Path(self.temp_dir)
+        self.integrator = SkillIntegrator()
+
+    def teardown_method(self):
+        shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+    def test_manifest_removal_with_copilot_user_scope(self):
+        """Manifest-based removal handles .copilot/skills/ paths."""
+        from dataclasses import replace as dc_replace
+        from apm_cli.integration.targets import KNOWN_TARGETS
+
+        copilot = KNOWN_TARGETS["copilot"]
+        resolved = dc_replace(copilot, root_dir=".copilot")
+
+        skills_dir = self.project_root / ".copilot" / "skills" / "my-skill"
+        skills_dir.mkdir(parents=True)
+        (skills_dir / "SKILL.md").write_text("# Skill")
+
+        managed = {".copilot/skills/my-skill"}
+        apm_package = Mock()
+        result = self.integrator.sync_integration(
+            apm_package, self.project_root,
+            managed_files=managed, targets=[resolved],
+        )
+
+        assert result["files_removed"] == 1
+        assert not skills_dir.exists()
+
+    def test_manifest_removal_with_config_opencode(self):
+        """Manifest-based removal handles .config/opencode/skills/ paths."""
+        from dataclasses import replace as dc_replace
+        from apm_cli.integration.targets import KNOWN_TARGETS
+
+        opencode = KNOWN_TARGETS["opencode"]
+        resolved = dc_replace(opencode, root_dir=".config/opencode")
+
+        skills_dir = self.project_root / ".config" / "opencode" / "skills" / "test-skill"
+        skills_dir.mkdir(parents=True)
+        (skills_dir / "SKILL.md").write_text("# Skill")
+
+        managed = {".config/opencode/skills/test-skill"}
+        apm_package = Mock()
+        result = self.integrator.sync_integration(
+            apm_package, self.project_root,
+            managed_files=managed, targets=[resolved],
+        )
+
+        assert result["files_removed"] == 1
+        assert not skills_dir.exists()
+
+    def test_manifest_removal_preserves_unmanaged(self):
+        """Managed-file removal does not touch unmanaged skill directories."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+
+        copilot = KNOWN_TARGETS["copilot"]
+
+        skills_dir = self.project_root / ".github" / "skills"
+        (skills_dir / "managed-skill").mkdir(parents=True)
+        (skills_dir / "managed-skill" / "SKILL.md").write_text("# Managed")
+        (skills_dir / "user-skill").mkdir(parents=True)
+        (skills_dir / "user-skill" / "SKILL.md").write_text("# User")
+
+        managed = {".github/skills/managed-skill"}
+        apm_package = Mock()
+        result = self.integrator.sync_integration(
+            apm_package, self.project_root,
+            managed_files=managed, targets=[copilot],
+        )
+
+        assert result["files_removed"] == 1
+        assert not (skills_dir / "managed-skill").exists()
+        assert (skills_dir / "user-skill").exists()
+
+    def test_backward_compat_no_targets_uses_known_targets(self):
+        """Without targets param, falls back to KNOWN_TARGETS (project scope)."""
+        skills_dir = self.project_root / ".github" / "skills" / "orphan-skill"
+        skills_dir.mkdir(parents=True)
+        (skills_dir / "SKILL.md").write_text("# Orphan")
+
+        managed = {".github/skills/orphan-skill"}
+        apm_package = Mock()
+        result = self.integrator.sync_integration(
+            apm_package, self.project_root,
+            managed_files=managed,
+        )
+
+        assert result["files_removed"] == 1
+
+    def test_legacy_cleanup_uses_target_dirs(self):
+        """Legacy orphan cleanup iterates target skill dirs dynamically."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+
+        copilot = KNOWN_TARGETS["copilot"]
+
+        # Create a skill dir that's NOT in installed deps (orphan)
+        skills_dir = self.project_root / ".github" / "skills" / "orphan"
+        skills_dir.mkdir(parents=True)
+        (skills_dir / "SKILL.md").write_text("# Orphan")
+
+        apm_package = Mock()
+        apm_package.get_apm_dependencies.return_value = []
+
+        result = self.integrator.sync_integration(
+            apm_package, self.project_root,
+            managed_files=None, targets=[copilot],
+        )
+
+        assert result["files_removed"] == 1
+        assert not skills_dir.exists()
+
+    def test_agents_skills_cleanup_requires_codex_dir(self):
+        """Cross-tool .agents/skills/ only cleaned when .codex/ exists."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+
+        codex = KNOWN_TARGETS["codex"]
+
+        agents_skills = self.project_root / ".agents" / "skills" / "orphan"
+        agents_skills.mkdir(parents=True)
+        (agents_skills / "SKILL.md").write_text("# Orphan")
+
+        apm_package = Mock()
+        apm_package.get_apm_dependencies.return_value = []
+
+        # Without .codex/ dir, should NOT clean .agents/skills/
+        result = self.integrator.sync_integration(
+            apm_package, self.project_root,
+            managed_files=None, targets=[codex],
+        )
+        assert result["files_removed"] == 0
+        assert agents_skills.exists()
+
+        # With .codex/ dir, should clean
+        (self.project_root / ".codex").mkdir()
+        result = self.integrator.sync_integration(
+            apm_package, self.project_root,
+            managed_files=None, targets=[codex],
+        )
+        assert result["files_removed"] == 1
+        assert not agents_skills.exists()
+
+
+class TestUninstallPhase2SkillTargets:
+    """Verify that skill re-integration during uninstall uses resolved targets.
+
+    Issue #538: copy_skill_to_target() and uninstall Phase 2 must respect
+    scope-resolved targets so user-scope re-integration deploys to the
+    correct directories.
+    """
+
+    def setup_method(self):
+        self.temp_dir = tempfile.mkdtemp()
+        self.project_root = Path(self.temp_dir)
+        self.apm_modules = self.project_root / "apm_modules"
+        self.apm_modules.mkdir(parents=True)
+        self.integrator = SkillIntegrator()
+
+    def teardown_method(self):
+        shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+    def test_copy_skill_to_target_respects_resolved_targets(self):
+        """copy_skill_to_target deploys to resolved root_dir from targets."""
+        from dataclasses import replace as dc_replace
+        from apm_cli.integration.targets import KNOWN_TARGETS
+
+        # Create a resolved copilot target (user scope: .copilot instead of .github)
+        copilot = KNOWN_TARGETS["copilot"]
+        resolved = dc_replace(copilot, root_dir=".copilot")
+        (self.project_root / ".copilot").mkdir()
+
+        skill_source = self.apm_modules / "owner" / "my-skill"
+        skill_source.mkdir(parents=True)
+        (skill_source / "SKILL.md").write_text("---\nname: my-skill\n---\n# My Skill")
+
+        pi = Mock()
+        pi.install_path = skill_source
+        pi.package = Mock()
+        pi.package.name = "my-skill"
+        pi.package_type = PackageType.CLAUDE_SKILL
+
+        deployed = copy_skill_to_target(
+            pi, skill_source, self.project_root, targets=[resolved],
+        )
+
+        assert len(deployed) == 1
+        assert ".copilot" in str(deployed[0])
+        assert (self.project_root / ".copilot" / "skills" / "my-skill" / "SKILL.md").exists()
+        assert not (self.project_root / ".github" / "skills").exists()
+
+    def test_copy_skill_to_target_auto_create_guard(self):
+        """copy_skill_to_target skips auto_create=False targets with no dir."""
+        from apm_cli.integration.targets import KNOWN_TARGETS
+
+        opencode = KNOWN_TARGETS["opencode"]
+        assert opencode.auto_create is False
+        # Do NOT create .opencode/
+
+        skill_source = self.apm_modules / "owner" / "my-skill"
+        skill_source.mkdir(parents=True)
+        (skill_source / "SKILL.md").write_text("---\nname: my-skill\n---\n# My Skill")
+
+        pi = Mock()
+        pi.install_path = skill_source
+        pi.package = Mock()
+        pi.package.name = "my-skill"
+        pi.package_type = PackageType.CLAUDE_SKILL
+
+        deployed = copy_skill_to_target(
+            pi, skill_source, self.project_root, targets=[opencode],
+        )
+
+        assert len(deployed) == 0
+        assert not (self.project_root / ".opencode" / "skills").exists()
+
+    def test_copy_skill_to_target_fallback_without_targets(self):
+        """copy_skill_to_target falls back to active_targets when no targets given."""
+        skill_source = self.apm_modules / "owner" / "my-skill"
+        skill_source.mkdir(parents=True)
+        (skill_source / "SKILL.md").write_text("---\nname: my-skill\n---\n# My Skill")
+
+        pi = Mock()
+        pi.install_path = skill_source
+        pi.package = Mock()
+        pi.package.name = "my-skill"
+        pi.package_type = PackageType.CLAUDE_SKILL
+
+        # No targets param -- should use active_targets fallback (copilot default)
+        deployed = copy_skill_to_target(
+            pi, skill_source, self.project_root,
+        )
+
+        assert len(deployed) == 1
+        assert (self.project_root / ".github" / "skills" / "my-skill" / "SKILL.md").exists()
