Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `apm install -g` now deploys files to the correct user-scope directories for all integrators; fixes OpenCode deploying to `.opencode/` instead of `.config/opencode/` and similar path mismatches for Copilot (#537, #530)

## [0.8.9] - 2026-03-31

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -1065,7 +1065,7 @@ def _log_integration(msg):
_int_result = getattr(_integrator, _method_name)(
_target, package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
diagnostics=diagnostics, user_scope=_user_scope,
)
if _int_result.files_integrated > 0:
result[_counter_key] += _int_result.files_integrated
Expand All @@ -1088,7 +1088,7 @@ def _log_integration(msg):
skill_result = skill_integrator.integrate_package_skill(
package_info, project_root,
diagnostics=diagnostics, managed_files=managed_files, force=force,
targets=targets,
targets=targets, user_scope=_user_scope,
)
_skill_target_dirs: set[str] = builtins.set()
for tp in skill_result.target_paths:
Expand Down
4 changes: 2 additions & 2 deletions src/apm_cli/commands/uninstall/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ 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):
def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_files, logger, user_scope: bool = False):
"""Remove deployed files and re-integrate from remaining packages."""
from ...integration.base_integrator import BaseIntegrator
from ...models.apm_package import PackageInfo, validate_apm_package
Expand Down Expand Up @@ -278,7 +278,7 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
_managed_subset = _buckets.get(_bucket_key, set())
result = _integrator.sync_for_target(
_target, apm_package, project_root,
managed_files=_managed_subset,
managed_files=_managed_subset, user_scope=user_scope,
)
counts[_counter_key] += result.get("files_removed", 0)

Expand Down
8 changes: 5 additions & 3 deletions src/apm_cli/integration/agent_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def integrate_agents_for_target(
force: bool = False,
managed_files: set = None,
diagnostics=None,
user_scope: bool = False,
) -> IntegrationResult:
"""Integrate agents from a package for a single *target*.

Expand All @@ -110,9 +111,9 @@ def integrate_agents_for_target(
if not mapping:
return IntegrationResult(0, 0, 0, [])

effective_root = mapping.deploy_root or target.root_dir
effective_root = mapping.deploy_root or target.effective_root(user_scope=user_scope)
target_root = project_root / effective_root
if not target.auto_create and not (project_root / target.root_dir).is_dir():
if not target.auto_create and not (project_root / target.effective_root(user_scope=user_scope)).is_dir():
return IntegrationResult(0, 0, 0, [])

self.init_link_resolver(package_info, project_root)
Expand Down Expand Up @@ -165,12 +166,13 @@ def sync_for_target(
apm_package,
project_root: Path,
managed_files: set = None,
user_scope: bool = False,
) -> Dict[str, int]:
"""Remove APM-managed agent files for a single *target*."""
mapping = target.primitives.get("agents")
if not mapping:
return {"files_removed": 0, "errors": 0}
effective_root = mapping.deploy_root or target.root_dir
effective_root = mapping.deploy_root or target.effective_root(user_scope=user_scope)
prefix = f"{effective_root}/{mapping.subdir}/"
legacy_dir = project_root / effective_root / mapping.subdir
# Copilot uses .agent.md suffix; others use plain .md
Expand Down
8 changes: 5 additions & 3 deletions src/apm_cli/integration/command_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def integrate_commands_for_target(
force: bool = False,
managed_files: set = None,
diagnostics=None,
user_scope: bool = False,
) -> IntegrationResult:
"""Integrate prompt files as commands for a single *target*.

Expand All @@ -130,9 +131,9 @@ def integrate_commands_for_target(
if not mapping:
return IntegrationResult(0, 0, 0, [], 0)

effective_root = mapping.deploy_root or target.root_dir
effective_root = mapping.deploy_root or target.effective_root(user_scope=user_scope)
target_root = project_root / effective_root
if not target.auto_create and not (project_root / target.root_dir).is_dir():
if not target.auto_create and not (project_root / target.effective_root(user_scope=user_scope)).is_dir():
return IntegrationResult(0, 0, 0, [], 0)

prompt_files = self.find_prompt_files(package_info.install_path)
Expand Down Expand Up @@ -185,12 +186,13 @@ def sync_for_target(
apm_package,
project_root: Path,
managed_files: set = None,
user_scope: bool = False,
) -> Dict:
"""Remove APM-managed command files for a single *target*."""
mapping = target.primitives.get("commands")
if not mapping:
return {"files_removed": 0, "errors": 0}
effective_root = mapping.deploy_root or target.root_dir
effective_root = mapping.deploy_root or target.effective_root(user_scope=user_scope)
prefix = f"{effective_root}/{mapping.subdir}/"
legacy_dir = project_root / effective_root / mapping.subdir
return self.sync_remove_files(
Expand Down
8 changes: 5 additions & 3 deletions src/apm_cli/integration/instruction_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def integrate_instructions_for_target(
force: bool = False,
managed_files: Optional[Set[str]] = None,
diagnostics=None,
user_scope: bool = False,
) -> IntegrationResult:
"""Integrate instructions for a single *target*.

Expand All @@ -72,9 +73,9 @@ def integrate_instructions_for_target(
if not mapping:
return IntegrationResult(0, 0, 0, [])

effective_root = mapping.deploy_root or target.root_dir
effective_root = mapping.deploy_root or target.effective_root(user_scope=user_scope)
target_root = project_root / effective_root
if not target.auto_create and not (project_root / target.root_dir).is_dir():
if not target.auto_create and not (project_root / target.effective_root(user_scope=user_scope)).is_dir():
return IntegrationResult(0, 0, 0, [])

self.init_link_resolver(package_info, project_root)
Expand Down Expand Up @@ -134,12 +135,13 @@ def sync_for_target(
apm_package,
project_root: Path,
managed_files: Optional[Set[str]] = None,
user_scope: bool = False,
) -> Dict[str, int]:
"""Remove APM-managed instruction files for a single *target*."""
mapping = target.primitives.get("instructions")
if not mapping:
return {"files_removed": 0, "errors": 0}
effective_root = mapping.deploy_root or target.root_dir
effective_root = mapping.deploy_root or target.effective_root(user_scope=user_scope)
prefix = f"{effective_root}/{mapping.subdir}/"
legacy_dir = project_root / effective_root / mapping.subdir
legacy_pattern = (
Expand Down
6 changes: 4 additions & 2 deletions src/apm_cli/integration/prompt_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,14 @@ def integrate_prompts_for_target(
force: bool = False,
managed_files: Optional[Set[str]] = None,
diagnostics=None,
user_scope: bool = False,
) -> IntegrationResult:
"""Integrate prompts for a single *target*."""
mapping = target.primitives.get("prompts")
if not mapping:
return IntegrationResult(0, 0, 0, [])

if not target.auto_create and not (project_root / target.root_dir).is_dir():
if not target.auto_create and not (project_root / target.effective_root(user_scope=user_scope)).is_dir():
return IntegrationResult(0, 0, 0, [])

return self.integrate_package_prompts(
Expand All @@ -105,12 +106,13 @@ def sync_for_target(
apm_package,
project_root: Path,
managed_files: Optional[Set[str]] = None,
user_scope: bool = False,
) -> Dict[str, int]:
"""Remove APM-managed prompt files for a single *target*."""
mapping = target.primitives.get("prompts")
if not mapping:
return {"files_removed": 0, "errors": 0}
effective_root = mapping.deploy_root or target.root_dir
effective_root = mapping.deploy_root or target.effective_root(user_scope=user_scope)
prefix = f"{effective_root}/{mapping.subdir}/"
legacy_dir = project_root / effective_root / mapping.subdir
return self.sync_remove_files(
Expand Down
69 changes: 56 additions & 13 deletions src/apm_cli/integration/skill_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ def copy_skill_to_target(
source_path: Path,
target_base: Path,
targets=None,
user_scope: bool = False,
) -> list[Path]:
"""Copy skill directory to all active target skills/ directories.
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

Expand Down Expand Up @@ -308,7 +309,13 @@ def copy_skill_to_target(
if not target.supports("skills"):
continue
skills_mapping = target.primitives["skills"]
effective_root = skills_mapping.deploy_root or target.root_dir
effective_root = skills_mapping.deploy_root or target.effective_root(user_scope=user_scope)

# Skip if target dir doesn't exist and auto_create is disabled
target_root_dir = target_base / target.effective_root(user_scope=user_scope)
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():
Expand Down Expand Up @@ -574,7 +581,7 @@ def _build_skill_ownership_map(project_root: Path) -> dict[str, str]:
def _promote_sub_skills_standalone(
self, package_info, project_root: Path, diagnostics=None,
managed_files=None, force: bool = False, logger=None,
targets=None,
targets=None, user_scope: bool = False,
) -> tuple[int, list[Path]]:
"""Promote sub-skills from a package that is NOT itself a skill.

Expand Down Expand Up @@ -604,14 +611,23 @@ def _promote_sub_skills_standalone(
owned_by = self._build_skill_ownership_map(project_root)
count = 0
all_deployed: list[Path] = []
primary_assigned = False

for idx, target in enumerate(targets):
if not target.supports("skills"):
continue

is_primary = (idx == 0) # first active target owns diagnostics
skills_mapping = target.primitives["skills"]
effective_root = skills_mapping.deploy_root or target.root_dir
effective_root = skills_mapping.deploy_root or target.effective_root(user_scope=user_scope)

# Skip if target dir doesn't exist and auto_create is disabled
target_root_dir = project_root / target.effective_root(user_scope=user_scope)
if not target.auto_create and not target_root_dir.is_dir():
continue

is_primary = not primary_assigned
if is_primary:
primary_assigned = True

target_skills_root = project_root / effective_root / "skills"
target_skills_root.mkdir(parents=True, exist_ok=True)

Expand All @@ -633,7 +649,7 @@ def _promote_sub_skills_standalone(
def _integrate_native_skill(
self, package_info, project_root: Path, source_skill_md: Path,
diagnostics=None, managed_files=None, force: bool = False,
logger=None, targets=None,
logger=None, targets=None, user_scope: bool = False,
) -> SkillIntegrationResult:
"""Copy a native Skill (with existing SKILL.md) to all active targets.

Expand Down Expand Up @@ -707,14 +723,28 @@ def _integrate_native_skill(

owned_by = self._build_skill_ownership_map(project_root)
sub_skills_dir = package_path / ".apm" / "skills"
primary_effective_root = None
primary_assigned = False

for idx, target in enumerate(targets):
if not target.supports("skills"):
continue

is_primary = (idx == 0) # first active target owns diagnostics
skills_mapping = target.primitives["skills"]
effective_root = skills_mapping.deploy_root or target.root_dir
effective_root = skills_mapping.deploy_root or target.effective_root(user_scope=user_scope)

# Skip if target dir doesn't exist and auto_create is disabled
target_root_dir = project_root / target.effective_root(user_scope=user_scope)
if not target.auto_create and not target_root_dir.is_dir():
continue
Comment thread
sergio-sisternes-epam marked this conversation as resolved.

if primary_effective_root is None:
primary_effective_root = effective_root

is_primary = not primary_assigned
if is_primary:
primary_assigned = True

target_skill_dir = project_root / effective_root / "skills" / skill_name

if is_primary:
Expand Down Expand Up @@ -747,8 +777,21 @@ def _integrate_native_skill(
)
all_target_paths.extend(sub_deployed)

if not all_target_paths:
# Every target was skipped (auto_create guard)
return SkillIntegrationResult(
skill_created=False,
skill_updated=False,
skill_skipped=True,
skill_path=None,
references_copied=0,
links_resolved=0,
sub_skills_promoted=0,
target_paths=[],
)

# Count unique sub-skills from primary target only
primary_root = project_root / ".github" / "skills"
primary_root = project_root / (primary_effective_root or ".github") / "skills"
sub_skills_count = sum(
1 for p in all_target_paths
if p.parent == primary_root and p.name != skill_name
Expand All @@ -765,7 +808,7 @@ def _integrate_native_skill(
target_paths=all_target_paths
)

def integrate_package_skill(self, package_info, project_root: Path, diagnostics=None, managed_files=None, force: bool = False, logger=None, targets=None) -> SkillIntegrationResult:
def integrate_package_skill(self, package_info, project_root: Path, diagnostics=None, managed_files=None, force: bool = False, logger=None, targets=None, user_scope: bool = False) -> SkillIntegrationResult:
"""Integrate a package's skill into all active target directories.

Copies native skills (packages with SKILL.md at root) to every active
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Comment thread
sergio-sisternes-epam marked this conversation as resolved.
Expand Down Expand Up @@ -793,7 +836,7 @@ def integrate_package_skill(self, package_info, project_root: Path, diagnostics=
# Even non-skill packages may ship sub-skills under .apm/skills/.
# Promote them so Copilot can discover them independently.
sub_skills_count, sub_deployed = self._promote_sub_skills_standalone(
package_info, project_root, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger, targets=targets
package_info, project_root, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger, targets=targets, user_scope=user_scope
)
return SkillIntegrationResult(
skill_created=False,
Expand Down Expand Up @@ -826,12 +869,12 @@ def integrate_package_skill(self, package_info, project_root: Path, diagnostics=
# Check if this is a native Skill (already has SKILL.md at root)
source_skill_md = package_path / "SKILL.md"
if source_skill_md.exists():
return self._integrate_native_skill(package_info, project_root, source_skill_md, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger, targets=targets)
return self._integrate_native_skill(package_info, project_root, source_skill_md, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger, targets=targets, user_scope=user_scope)

# No SKILL.md at root -- not a skill package.
# Still promote any sub-skills shipped under .apm/skills/.
sub_skills_count, sub_deployed = self._promote_sub_skills_standalone(
package_info, project_root, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger, targets=targets
package_info, project_root, diagnostics=diagnostics, managed_files=managed_files, force=force, logger=logger, targets=targets, user_scope=user_scope
)
return SkillIntegrationResult(
skill_created=False,
Expand Down
Loading
Loading