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
5 changes: 1 addition & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,7 @@ 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)
- `apm install -g` now correctly deploys to user-scope directories, skips unsupported primitives, and cleans up on uninstall -- including multi-level paths like `~/.config/opencode/` (#542)

### Added

Expand Down
11 changes: 8 additions & 3 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -2334,13 +2334,18 @@ def _collect_descendants(node, visited=None):
_removed_orphan_count = 0
_failed_orphan_count = 0
_deleted_orphan_paths: builtins.list = []
# Build validation targets that cover both default KNOWN_TARGETS
# and scope-resolved targets so legacy project-scope orphan paths
# (e.g., ".github/...") are also cleaned up at user scope.
_validation_targets = _targets or {}
_default_targets = getattr(BaseIntegrator, "KNOWN_TARGETS", None)
if _default_targets:
_validation_targets = {**_default_targets, **_validation_targets}
for _orphan_path in sorted(orphaned_deployed_files):
# validate_deploy_path() is the safety gate: it rejects path-traversal,
# 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):
if BaseIntegrator.validate_deploy_path(_orphan_path, project_root, targets=_validation_targets):
_target = project_root / _orphan_path
if _target.exists():
try:
Expand Down
18 changes: 14 additions & 4 deletions src/apm_cli/commands/uninstall/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,21 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f

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.
# Partition against default KNOWN_TARGETS for legacy/project-scope
# paths, then merge with resolved targets for user-scope paths.
# This ensures both .github/ (legacy) and .copilot/ (resolved)
# prefixes are recognized during uninstall cleanup.
_buckets = BaseIntegrator.partition_managed_files(sync_managed)
if user_scope and _resolved_targets:
_scope_buckets = BaseIntegrator.partition_managed_files(
sync_managed, targets=_resolved_targets
)
Comment on lines 254 to +258
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.

When user_scope is true, _resolved_targets is scope-resolved via TargetProfile.for_scope(), which filters out unsupported_user_primitives (e.g., Copilot removes "instructions"). As a result, partition_managed_files(..., targets=_resolved_targets) will not recognize/bucket managed paths for those filtered primitives under user-scope roots (e.g., legacy ".copilot/instructions/..." entries), so Phase 1 sync will never remove them even if they are present in all_deployed_files. If the intent is to clean up legacy installs, consider partitioning/cleanup against a target set that resolves user_root_dir but does not drop primitives (or otherwise explicitly include unsupported-primitive prefixes for removal only).

Copilot uses AI. Check for mistakes.
for _bname, _bpaths in _scope_buckets.items():
_existing = _buckets.get(_bname)
if _existing is not None:
_existing.update(_bpaths)
else:
_buckets[_bname] = _bpaths
else:
_buckets = None

Expand Down
4 changes: 2 additions & 2 deletions src/apm_cli/integration/skill_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ def copy_skill_to_target(
# Get and validate skill name from folder
raw_skill_name = source_path.name

is_valid, error_msg = validate_skill_name(raw_skill_name)
is_valid, _ = validate_skill_name(raw_skill_name)
if is_valid:
skill_name = raw_skill_name
else:
Expand Down Expand Up @@ -918,7 +918,7 @@ 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, error_msg = validate_skill_name(raw_name)
is_valid, _ = validate_skill_name(raw_name)
skill_name = raw_name if is_valid else normalize_skill_name(raw_name)
installed_skill_names.add(skill_name)

Expand Down
Loading