fix: scope-resolved target profiles + Claude Code instructions#542
fix: scope-resolved target profiles + Claude Code instructions#542danielmeppiel merged 4 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR centralizes scope resolution for integration targets (project vs user scope) and expands Claude Code support by deploying .instructions.md as .claude/rules/*.md with applyTo: converted to Claude's paths: frontmatter, while extending hook path rewriting to cover additional platform-specific keys.
Changes:
- Add
TargetProfile.for_scope()andresolve_targets()to resolve scope exactly once and remove scope-threading from downstream integration logic. - Add Claude Code instructions support (
claude_rules) with conversion + sync behavior that avoids glob-deleting user-authored.mdfiles. - Extend hook script-path rewriting to include additional VS Code OS-specific keys (
windows,linux,osx) and add tests for these cases.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/integration/targets.py |
Adds scope-resolved target profiles (for_scope) and resolve_targets, plus Claude instructions mapping and Copilot user-scope primitive filtering. |
src/apm_cli/integration/base_integrator.py |
Adds targets= plumbing for validation/partitioning and changes partition routing to longest-prefix matching. |
src/apm_cli/commands/install.py |
Switches target detection to resolve_targets, adjusts logging/dir creation, uses targets= for deploy-path validation, and fixes dependency resolution root for user scope. |
src/apm_cli/commands/uninstall/engine.py |
Uses scope-resolved targets for both Phase 1 cleanup and Phase 2 re-integration, and partitions managed files using resolved targets. |
src/apm_cli/commands/uninstall/cli.py |
Passes user_scope into uninstall integration sync. |
src/apm_cli/integration/instruction_integrator.py |
Adds Claude rules conversion/copy/sync + format-driven instruction dispatch. |
src/apm_cli/integration/hook_integrator.py |
Expands script-path key handling via HOOK_COMMAND_KEYS and updates rewrite loops accordingly. |
tests/unit/integration/test_instruction_integrator.py |
Adds conversion + integration + sync tests for Claude rules. |
tests/unit/integration/test_hook_integrator.py |
Adds rewrite tests for windows/linux/osx keys and expands coverage of multi-key scenarios. |
tests/unit/integration/test_data_driven_dispatch.py |
Adds bucket alias/routing tests for Claude rules and validates prefix/partition behavior with explicit targets. |
tests/unit/core/test_scope.py |
Updates Copilot user-scope expectations to include instructions as unsupported. |
docs/src/content/docs/integrations/ide-tool-integration.md |
Documents .claude/rules/*.md as a managed Claude integration location and updates sync semantics to include rules. |
docs/src/content/docs/guides/dependencies.md |
Updates Copilot CLI user-scope primitive support table (removes instructions). |
CHANGELOG.md |
Adds Unreleased entries for Claude rules + scope-resolved targets + user-scope fixes. |
uv.lock |
Updates editable apm-cli version entry. |
Comments suppressed due to low confidence (1)
src/apm_cli/integration/base_integrator.py:133
- validate_deploy_path() still uses a naive substring check (".." in rel_path) for path traversal. This can be bypassed with mixed separators or path normalization edge cases, and it duplicates logic that should live in utils/path_security.py. Consider switching to validate_path_segments(rel_path, context=...) (after normalizing backslashes) and ensure_path_within(resolved_target, project_root) as the authoritative traversal/containment checks.
if allowed_prefixes is None:
allowed_prefixes = BaseIntegrator._get_integration_prefixes(targets=targets)
if ".." in rel_path:
return False
if not rel_path.startswith(allowed_prefixes):
return False
target = project_root / rel_path
try:
if not target.resolve().is_relative_to(project_root.resolve()):
return False
Introduce TargetProfile.for_scope() and resolve_targets() so scope resolution happens exactly once -- no parameter threading, no caller responsibility. This supersedes PRs #536 and #537 and folds in #527. Scope resolution: - for_scope(user_scope) returns a frozen copy with root_dir resolved and unsupported primitives filtered out - resolve_targets() is the single entry point combining detection, scope resolution, and primitive filtering - Integrators read target.root_dir directly -- always correct - partition_managed_files() uses longest-prefix-match routing, fixing .config/opencode/ multi-level root paths - validate_deploy_path() derives prefixes from resolved targets - Uninstall Phase 2 re-integration uses resolved targets, eliminating wrong-path deployment bug Claude Code instructions (#527): - Deploy .instructions.md to .claude/rules/*.md with applyTo: to paths: frontmatter conversion - Legacy glob disabled for .claude/rules/ to protect user .md files - 22 new tests for conversion, integration, sync, and partition Copilot CLI user-scope fix (#536): - Add instructions to unsupported_user_primitives (per official docs) - Fix resolve_dependencies to use apm_dir at user scope Fixes #530 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
e316290 to
7036e5a
Compare
…LOG refs - Replace O(M*P) sorted-prefix scan with O(depth) trie lookup in partition_managed_files (reviewer comment 1) - Use default KNOWN_TARGETS for uninstall partition so legacy deployed_files from buggy user-scope installs are still bucketed and cleaned up (reviewer comment 2) - Remove targets= from orphan validate_deploy_path so legacy .github/ paths from old user-scope installs can be cleaned up (reviewer comment 3) - Fix CHANGELOG PR references to use #542 (reviewer comment 4) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fix #538: pass resolved targets to skill_integrator.integrate_package_skill() in uninstall Phase 2 so user-scope re-integration deploys to .copilot/skills/ not .github/skills/. Add auto_create guard to copy_skill_to_target(). Fix #539: refactor sync_integration() to derive skill prefixes dynamically from targets instead of hardcoded .github/skills/, .claude/skills/, etc. User-scope paths like .copilot/skills/ and .config/opencode/skills/ are now handled correctly. Pass resolved targets from uninstall engine. 9 new tests covering resolved-target routing, auto_create guards, user-scope manifest removal, legacy cleanup, and cross-tool guards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
What this PR doesUser-scope deployment ( This PR eliminates the problem at its root: scope resolution happens once, on the data object itself, before any code sees it.
How it solves each linked issue#530 -- skills deploy to wrong user-scope path: The core bug. #538 -- #539 -- Also cherry-picks from superseded PRs: Copilot |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (4)
src/apm_cli/commands/uninstall/engine.py:319
HookIntegrator.sync_integration()does its own manifest-based removal with hard-coded prefixes (e.g..github/hooks/,.claude/hooks/). In user scope, resolved targets will deploy hooks under directories like.copilot/hooks/, but this call does not pass any target context and HookIntegrator currently won't match those paths, so user-scope hooks can be left behind on uninstall. Consider updating HookIntegrator's removal logic to derive hook prefixes from the resolved targets (similar to skills) or accept atargets=parameter.
# 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)
counts["hooks"] = result.get("files_removed", 0)
src/apm_cli/integration/base_integrator.py:129
validate_deploy_path()now supports atargets=parameter, but the main removal helpersync_remove_files()(used by prompt/agent/command/instruction integrators) still callsvalidate_deploy_path(rel_path, project_root)without passing scope-resolved targets/allowed prefixes. As a result, any user-scope deployed path under.copilot/or.config/opencode/will fail validation and not be removed during sync/uninstall. Consider extendingsync_remove_files()to accepttargets(orallowed_prefixes) and wiring it through allsync_for_target()call sites.
@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(targets=targets)
if ".." in rel_path:
return False
if not rel_path.startswith(allowed_prefixes):
return False
src/apm_cli/integration/skill_integrator.py:905
- The containment check here uses string-prefix matching on
str(target.resolve()), which is not a safe substitute for a real path containment test (it can be fooled by symlinks or prefixes like/tmp/root2starting with/tmp/root). Since this code canrmtree()directories from lockfile-provided paths, it should reuse the centralized guard (e.g.,BaseIntegrator.validate_deploy_path(..., targets=...)orensure_path_within()fromutils/path_security.py) rather than".." in rel_path+startswith()on strings.
if ".." in rel_path:
continue
target = project_root / rel_path
if not str(target.resolve()).startswith(str(project_root_resolved)):
continue
src/apm_cli/commands/uninstall/engine.py:296
- Phase 1 calls per-target
sync_for_target(), but those sync methods ultimately rely onBaseIntegrator.sync_remove_files(), which callsvalidate_deploy_path()without passing the scope-resolvedtargets. That means user-scope prefixes like.copilot//.config/opencode/will be rejected by the safety gate and silently not removed even when_target.root_diris resolved. Suggest threading resolved targets (or allowed_prefixes) into the sync/remove helpers so user-scope uninstall actually deletes tracked files.
for _target in _resolved_targets:
for _prim_name, _mapping in _target.primitives.items():
if _prim_name in ("skills", "hooks"):
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():
continue
_managed_subset = None
if _buckets is not None:
_bucket_key = BaseIntegrator.partition_bucket_key(
_prim_name, _target.name
)
_managed_subset = _buckets.get(_bucket_key, set())
result = _integrator.sync_for_target(
_target, apm_package, project_root,
managed_files=_managed_subset,
)
counts[_counter_key] += result.get("files_removed", 0)
| # cleaned up during uninstall. | ||
| _buckets = BaseIntegrator.partition_managed_files(sync_managed) |
There was a problem hiding this comment.
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.
| # 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) |
| # 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): |
There was a problem hiding this comment.
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,
):
| 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) |
There was a problem hiding this comment.
error_msg is assigned but never used. To avoid unused-variable noise and clarify intent, consider using _ here (or logging/recording the validation error if it's meant to be surfaced).
| is_valid, error_msg = validate_skill_name(raw_name) | |
| is_valid, _ = validate_skill_name(raw_name) |
| - `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) |
There was a problem hiding this comment.
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 assess if latest unresolved comments are legit and propose engancements in a separate PR if helpful (against main) |
- Partition uninstall managed files with both default and resolved targets so user-scope paths are bucketed correctly - Pass resolved targets to orphan validate_deploy_path so user-scope paths are accepted for cleanup - Fix unused variable (error_msg -> _) - Consolidate CHANGELOG Fixed entries per repo convention Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: address post-merge review on #542 - Partition uninstall managed files with both default and resolved targets so user-scope paths are bucketed correctly - Pass resolved targets to orphan validate_deploy_path so user-scope paths are accepted for cleanup - Fix unused variable (error_msg -> _) - Consolidate CHANGELOG Fixed entries per repo convention Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: validate orphan paths against both default and resolved targets Merge KNOWN_TARGETS with scope-resolved targets for orphan cleanup validation, matching the pattern used in uninstall engine. This ensures legacy project-scope orphan paths (e.g., .github/...) are also cleaned up during user-scope installs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Description
Introduces
TargetProfile.for_scope()andresolve_targets()so scope resolution happens exactly once -- no parameter threading, no caller responsibility. Supersedes #536 and #537. Builds on #527 (already merged).Architecture: scope-resolved targets
The root cause of user-scope bugs: every code path that touches
target.root_dirmust choose the right value for the scope. Both #536 (dataclasses.replace at dispatch) and #537 (thread user_scope through every method) fix the install path but both miss uninstall Phase 2 re-integration, and neither handles multi-leveluser_root_dirvalues like.config/opencodein partition routing.Solution: resolve scope on the data object itself, once, at the entry point.
for_scope(user_scope)returns a frozen copy withroot_dirresolved and unsupported primitives filtered outresolve_targets()is the single entry point combining detection + scope resolution + filteringtarget.root_dirdirectly -- always correct for the current scopepartition_managed_files(targets=)uses trie-based O(depth) routing, fixing.config/opencode/multi-level root pathsvalidate_deploy_path(targets=)derives prefixes from resolved targetsSkill integrator scope fixes (#538, #539)
sync_integration()now derives skill prefixes dynamically from targets instead of hardcoded strings -- handles.copilot/skills/,.config/opencode/skills/, etc.integrate_package_skill()copy_skill_to_target()adds auto_create guard for targets without existing directoriesCopilot CLI user-scope fix (#536)
instructionstounsupported_user_primitives(per official docs)resolve_dependenciesto useapm_dirat user scopeFixes #530, #538, #539
Supersedes #536, #537
Type of change
Testing