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

## [Unreleased]

### Changed

- Scope resolution now happens once via `TargetProfile.for_scope()` and `resolve_targets()` -- integrators no longer need scope-aware parameters (#562)
- Unified integration dispatch table in `dispatch.py` -- both install and uninstall import from one source of truth (#562)
- Hook merge logic deduplicated: three copy-pasted JSON-merge methods replaced with `_integrate_merged_hooks()` + config dict (#562)
Comment on lines +11 to +15
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

This PR fixes apm deps update -g scope behavior, but the user-facing docs still don't list a global flag for apm deps update (e.g., docs/src/content/docs/reference/cli-commands.md and packages/apm-guide/.apm/skills/apm-usage/commands.md only list verbose/force/target/parallel-downloads). Please update the Starlight docs pages and the apm-guide command reference to document the global/user-scope behavior for deps update (and any related scope semantics impacted by the unified dispatch change).

Copilot uses AI. Check for mistakes.

### Fixed

- `apm deps update -g` now correctly passes scope, preventing user-scope updates from silently using project-scope paths (#562)

## [0.8.10] - 2026-04-03

### Fixed

- Hook integrator now processes the `windows` property in hook JSON files, copying referenced scripts and rewriting paths during install/compile (#311)
Expand Down
3 changes: 3 additions & 0 deletions docs/src/content/docs/guides/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,9 @@ apm deps update owner/apm-sample-package
# Update with verbose output
apm deps update --verbose

# Update user-scope dependencies
apm deps update -g

# Install with updates (equivalent to update)
apm install --update
```
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,7 @@ apm deps update [PACKAGES...] [OPTIONS]
**Options:**
- `--verbose, -v` - Show detailed update information
- `--force` - Overwrite locally-authored files on collision
- `-g, --global` - Update user-scope dependencies (`~/.apm/`)
- `--target, -t` - Force deployment to a specific target (copilot, claude, cursor, opencode, vscode, agents, all)
- `--parallel-downloads` - Max concurrent downloads (default: 4)

Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/commands/deps/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ def update(packages, verbose, force, target, parallel_downloads, global_):
logger=logger,
auth_resolver=auth_resolver,
target=target,
scope=scope,
)
except Exception as e:
logger.error(f"Update failed: {e}")
Expand Down
72 changes: 28 additions & 44 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -977,9 +977,9 @@ def _integrate_package_primitives(

Returns a dict with integration counters and the list of deployed file paths.
"""
from apm_cli.core.scope import InstallScope
from apm_cli.integration.dispatch import get_dispatch_table

_user_scope = scope is InstallScope.USER
_dispatch = get_dispatch_table()
result = {
"prompts": 0,
"agents": 0,
Expand All @@ -1001,63 +1001,47 @@ def _log_integration(msg):
if logger:
logger.tree_item(msg)

# Primitive -> (integrator, method_name, result counter key)
_PRIMITIVE_INTEGRATORS = {
"prompts": (prompt_integrator, "integrate_prompts_for_target", "prompts"),
"agents": (agent_integrator, "integrate_agents_for_target", "agents"),
"commands": (command_integrator, "integrate_commands_for_target", "commands"),
"instructions": (instruction_integrator, "integrate_instructions_for_target", "instructions"),
# Map integrator kwargs to dispatch table keys
_INTEGRATOR_KWARGS = {
"prompts": prompt_integrator,
"agents": agent_integrator,
"commands": command_integrator,
"instructions": instruction_integrator,
"hooks": hook_integrator,
"skills": skill_integrator,
}

# --- target x primitive dispatch loop ---
# --- per-target dispatch loop ---
for _target in targets:
for _prim_name, _mapping in _target.primitives.items():
if _prim_name == "skills":
continue # handled separately below

# --- hooks (different return type) ---
if _prim_name == "hooks":
hook_result = hook_integrator.integrate_hooks_for_target(
_target, package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)
if hook_result.hooks_integrated > 0:
result["hooks"] += hook_result.hooks_integrated
if _target.name == "claude":
_hook_dir = ".claude/settings.json"
elif _target.name == "cursor":
_hook_dir = ".cursor/hooks.json"
elif _target.name == "codex":
_hook_dir = ".codex/hooks.json"
else:
_effective_root = _mapping.deploy_root or _target.root_dir
_hook_dir = f"{_effective_root}/{_mapping.subdir}/" if _mapping.subdir else f"{_effective_root}/"
_log_integration(
f" |-- {hook_result.hooks_integrated} hook(s) integrated -> {_hook_dir}"
)
for tp in hook_result.target_paths:
deployed.append(tp.relative_to(project_root).as_posix())
continue
_entry = _dispatch.get(_prim_name)
if not _entry or _entry.multi_target:
continue # skills handled below

_entry = _PRIMITIVE_INTEGRATORS.get(_prim_name)
if not _entry:
continue

_integrator, _method_name, _counter_key = _entry
_int_result = getattr(_integrator, _method_name)(
_integrator = _INTEGRATOR_KWARGS[_prim_name]
_int_result = getattr(_integrator, _entry.integrate_method)(
_target, package_info, project_root,
force=force, managed_files=managed_files,
diagnostics=diagnostics,
)

if _int_result.files_integrated > 0:
result[_counter_key] += _int_result.files_integrated
result[_entry.counter_key] += _int_result.files_integrated
_effective_root = _mapping.deploy_root or _target.root_dir
_deploy_dir = f"{_effective_root}/{_mapping.subdir}/"
_deploy_dir = f"{_effective_root}/{_mapping.subdir}/" if _mapping.subdir else f"{_effective_root}/"
# Determine display label
if _prim_name == "instructions" and _mapping.format_id in ("cursor_rules", "claude_rules"):
_label = "rule(s)"
elif _prim_name == "instructions":
_label = "instruction(s)"
elif _prim_name == "hooks":
if _target.name == "claude":
_deploy_dir = ".claude/settings.json"
elif _target.name == "cursor":
_deploy_dir = ".cursor/hooks.json"
elif _target.name == "codex":
_deploy_dir = ".codex/hooks.json"
_label = "hook(s)"
else:
_label = _prim_name
_log_integration(
Expand Down
90 changes: 28 additions & 62 deletions src/apm_cli/commands/uninstall/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,14 +232,12 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
"""
from ...integration.base_integrator import BaseIntegrator
from ...models.apm_package import PackageInfo, validate_apm_package
from ...integration.prompt_integrator import PromptIntegrator
from ...integration.agent_integrator import AgentIntegrator
from ...integration.skill_integrator import SkillIntegrator
from ...integration.command_integrator import CommandIntegrator
from ...integration.hook_integrator import HookIntegrator
from ...integration.instruction_integrator import InstructionIntegrator
from ...integration.dispatch import get_dispatch_table
from ...integration.targets import resolve_targets

_dispatch = get_dispatch_table()
_integrators = {name: entry.integrator_class() for name, entry in _dispatch.items()}

# Resolve targets once -- used for both Phase 1 removal and Phase 2 re-integration.
config_target = apm_package.target
_explicit = config_target or None
Expand All @@ -265,30 +263,15 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
else:
_buckets = None

counts = {"prompts": 0, "agents": 0, "skills": 0, "commands": 0, "hooks": 0, "instructions": 0}
counts = {entry.counter_key: 0 for entry in _dispatch.values()}

# Phase 1: Remove all APM-deployed files
# Use target-driven sync for prompts, agents, commands, instructions
_prompt_int = PromptIntegrator()
_agent_int = AgentIntegrator()
_cmd_int = CommandIntegrator()
_instr_int = InstructionIntegrator()

_SYNC_DISPATCH = {
"prompts": (_prompt_int, "prompts"),
"agents": (_agent_int, "agents"),
"commands": (_cmd_int, "commands"),
"instructions": (_instr_int, "instructions"),
}

# Per-target sync for primitives with sync_for_target
for _target in _resolved_targets:
for _prim_name, _mapping in _target.primitives.items():
if _prim_name in ("skills", "hooks"):
_entry = _dispatch.get(_prim_name)
if not _entry or _entry.sync_method != "sync_for_target":
continue
_entry = _SYNC_DISPATCH.get(_prim_name)
if not _entry:
continue
_integrator, _counter_key = _entry
_effective_root = _mapping.deploy_root or _target.root_dir
_deploy_dir = project_root / _effective_root / _mapping.subdir
if not _deploy_dir.exists():
Expand All @@ -299,11 +282,11 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
_prim_name, _target.name
)
_managed_subset = _buckets.get(_bucket_key, set())
result = _integrator.sync_for_target(
result = _integrators[_prim_name].sync_for_target(
_target, apm_package, project_root,
managed_files=_managed_subset,
)
counts[_counter_key] += result.get("files_removed", 0)
counts[_entry.counter_key] += result.get("files_removed", 0)
Comment on lines 282 to +289
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

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

In Phase 1 user-scope uninstall, sync_for_target() ultimately calls BaseIntegrator.sync_remove_files(), which calls validate_deploy_path(rel_path, project_root) without passing scope-resolved targets/allowed_prefixes. That validation falls back to KNOWN_TARGETS prefixes (project-scope roots) and will reject user-scope paths like .copilot/... and .config/opencode/..., so those files will be silently skipped and left behind. Please plumb the resolved targets (or an allowlist derived from them) through the sync_remove_files/validate_deploy_path call chain so user-scope managed files can be removed without test monkeypatching.

Copilot uses AI. Check for mistakes.

# Skills (multi-target, handled by SkillIntegrator)
# Check both target root_dir and deploy_root for skill directories
Expand All @@ -316,36 +299,24 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
_skill_dirs_exist = True
break
if _skill_dirs_exist:
integrator = SkillIntegrator()
result = integrator.sync_integration(apm_package, project_root,
managed_files=_buckets["skills"] if _buckets else None,
targets=_resolved_targets)
result = _integrators["skills"].sync_integration(
apm_package, project_root,
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)
hook_integrator_cleanup = HookIntegrator()
result = hook_integrator_cleanup.sync_integration(apm_package, project_root,
managed_files=_buckets["hooks"] if _buckets else None)
# Hooks (multi-target sync_integration handles all targets)
result = _integrators["hooks"].sync_integration(
apm_package, project_root,
managed_files=_buckets["hooks"] if _buckets else None,
)
counts["hooks"] = result.get("files_removed", 0)


# Phase 2: Re-integrate from remaining installed packages
_targets = _resolved_targets

prompt_integrator = PromptIntegrator()
agent_integrator = AgentIntegrator()
skill_integrator = SkillIntegrator()
command_integrator = CommandIntegrator()
hook_integrator_reint = HookIntegrator()
instruction_integrator_reint = InstructionIntegrator()

_REINT_DISPATCH = {
"prompts": (prompt_integrator, "integrate_prompts_for_target"),
"agents": (agent_integrator, "integrate_agents_for_target"),
"commands": (command_integrator, "integrate_commands_for_target"),
"instructions": (instruction_integrator_reint, "integrate_instructions_for_target"),
}

for dep in apm_package.get_apm_dependencies():
dep_ref = dep if hasattr(dep, 'repo_url') else None
if not dep_ref:
Expand All @@ -367,20 +338,15 @@ def _sync_integrations_after_uninstall(apm_package, project_root, all_deployed_f
try:
for _target in _targets:
for _prim_name in _target.primitives:
if _prim_name == "skills":
_entry = _dispatch.get(_prim_name)
if not _entry or _entry.multi_target:
continue
if _prim_name == "hooks":
hook_integrator_reint.integrate_hooks_for_target(
_target, pkg_info, project_root,
)
continue
_entry = _REINT_DISPATCH.get(_prim_name)
if _entry:
_integrator, _method = _entry
getattr(_integrator, _method)(
_target, pkg_info, project_root,
)
skill_integrator.integrate_package_skill(pkg_info, project_root, targets=_targets)
getattr(_integrators[_prim_name], _entry.integrate_method)(
_target, pkg_info, project_root,
)
_integrators["skills"].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
5 changes: 5 additions & 0 deletions src/apm_cli/integration/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
)
from .skill_transformer import SkillTransformer
from .mcp_integrator import MCPIntegrator
from .coverage import check_primitive_coverage
from .dispatch import PrimitiveDispatch, get_dispatch_table
from .targets import (
TargetProfile,
PrimitiveMapping,
Expand All @@ -28,6 +30,9 @@
__all__ = [
'BaseIntegrator',
'IntegrationResult',
'check_primitive_coverage',
'PrimitiveDispatch',
'get_dispatch_table',
'PromptIntegrator',
'AgentIntegrator',
'HookIntegrator',
Expand Down
Loading
Loading