Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +14 to +17
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

These changes add multiple bullet entries for the same PR number (#542) under ### Fixed. The repo's changelog convention is one line per PR; consider collapsing these into a single concise bullet (and keep the remaining detail in the PR description) to match the established format.

Copilot generated this review using guidance from repository custom instructions.

### 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
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
41 changes: 11 additions & 30 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
)
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Comment on lines +2339 to 2343
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

This orphan cleanup always calls validate_deploy_path() with the default prefix allow-list (KNOWN_TARGETS). In user scope, lockfile paths will now legitimately include prefixes like .copilot/ and .config/opencode/, so they will be rejected and not cleaned up. Consider passing the current resolved targets (and optionally merging with default prefixes for legacy .github/ paths) so orphaned user-scope files/directories are actually removed.

See below for a potential fix:

            # Derive an allow-list of prefixes from the orphaned paths themselves,
            # so user-scope prefixes (e.g., .copilot/, .config/opencode/) are
            # considered valid for cleanup. Also include legacy .github paths.
            _orphan_allowed_prefixes: builtins.set = set()
            for _p in orphaned_deployed_files:
                _p_str = str(_p)
                _first_segment = _p_str.split("/", 1)[0] if _p_str else ""
                if _first_segment:
                    _orphan_allowed_prefixes.add(_first_segment)
            if GITHUB_DIR:
                _orphan_allowed_prefixes.add(GITHUB_DIR.strip("/"))
            for _orphan_path in sorted(orphaned_deployed_files):
                # validate_deploy_path() is the safety gate: it rejects path-traversal,
                # requires an allowed integration prefix, and checks the resolved path
                # stays within project_root -- so rmtree is safe here.
                # Pass the prefixes derived from the current orphan set so that
                # user-scope paths are also cleaned up, while still honoring
                # legacy .github deployments.
                if BaseIntegrator.validate_deploy_path(
                    _orphan_path,
                    project_root,
                    allowed_prefixes=_orphan_allowed_prefixes,
                ):

Copilot uses AI. Check for mistakes.
_target = project_root / _orphan_path
Comment thread
danielmeppiel marked this conversation as resolved.
if _target.exists():
Expand Down
5 changes: 4 additions & 1 deletion src/apm_cli/commands/uninstall/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 22 additions & 10 deletions src/apm_cli/commands/uninstall/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Comment on lines +253 to 254
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

The comment says we partition against both resolved and unresolved targets, but this call uses the default KNOWN_TARGETS only. At user scope that will not bucket paths under resolved roots like .copilot/... or .config/opencode/..., so _managed_subset (and the skills bucket) can be empty and Phase 1 cleanup will skip removing the actual deployed files. Consider partitioning with a combined target list (resolved + KNOWN_TARGETS) or partition twice (resolved + default) and union the buckets.

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

Suggested change
# cleaned up during uninstall.
_buckets = BaseIntegrator.partition_managed_files(sync_managed)
# cleaned up during uninstall.
# First partition using the default unresolved/legacy targets, then
# partition again with resolved targets and merge the buckets.
_buckets = BaseIntegrator.partition_managed_files(sync_managed)
if _resolved_targets:
_resolved_buckets = BaseIntegrator.partition_managed_files(
sync_managed, targets=_resolved_targets
)
for name, paths in _resolved_buckets.items():
bucket = _buckets.setdefault(name, builtins.set())
bucket.update(paths)

Copilot uses AI. Check for mistakes.
else:
_buckets = None
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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}")
Expand Down
87 changes: 57 additions & 30 deletions src/apm_cli/integration/base_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}/"
Expand All @@ -198,32 +208,49 @@ 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()

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):
Expand Down
Loading
Loading