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
5 changes: 5 additions & 0 deletions docs/src/content/docs/reference/cli/compile.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ and are not touched by `apm compile`. See
[Primitives and targets](../../../concepts/primitives-and-targets/)
for the full reach map.

After a successful non-dry-run compile, APM also reconciles deployed-file
ownership with the current `targets:` declaration. If the declared target set
contracts, artifacts and lockfile entries owned by the removed target are
cleaned up with the same hash and user-edit safeguards as `apm install`.

**When you actually need it:** compile is **optional for the
`copilot` target** -- GitHub Copilot natively reads
`.github/instructions/*.instructions.md` (with their `applyTo:`
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/reference/cli/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ apm update
- **Consent gate.** The prompt defaults to **No**. Without `--yes`, declining (or running in a non-interactive context) aborts with a clean exit; the manifest, lockfile, and workspace are untouched.
- **No partial consent.** A single prompt covers both revision-pin manifest rewrites and the normal update plan; declining leaves everything unchanged.
- **`--dry-run` skips the prompt.** It computes and prints the plan, including revision-pin SHA/tag rewrites, but never writes and never asks.
- **Target contraction is reconciled.** A successful update removes unchanged dependencies' deployed files and lockfile ownership for targets no longer declared in `apm.yml`, even when no dependency ref changes.

## Back-compat: `apm update` used to be the self-updater

Expand Down
4 changes: 2 additions & 2 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ If no `--target`, no `targets:` in `apm.yml`, and no harness signal is present,

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm compile` | Compile agent context | `-o` output, `-t` target (comma-separated; resolution chain `--target` > apm.yml `targets:` > auto-detect), `--all` compile for every canonical target (preferred over deprecated `--target all`), `-g`/`--global` (read global instructions from `~/.apm/apm_modules/`, write user-scope root files; cannot combine with project-output flags such as `--target`, `--all`, `--watch`, `--root`, or `--output`; critical hidden-character findings stop the write and exit 1), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution`, `--force-instructions` / `--no-dedup` (opt out of Claude/Copilot deduplication), `--root DIR` redirect generated artifacts under DIR while sources resolve from `$PWD` (mirrors `pip install --target`; not valid with `--watch`) |
| `apm compile` | Compile agent context; after a successful write, reconcile deployed artifacts and lockfile ownership when the declared target set contracts | `-o` output, `-t` target (comma-separated; resolution chain `--target` > apm.yml `targets:` > auto-detect), `--all` compile for every canonical target (preferred over deprecated `--target all`), `-g`/`--global` (read global instructions from `~/.apm/apm_modules/`, write user-scope root files; cannot combine with project-output flags such as `--target`, `--all`, `--watch`, `--root`, or `--output`; critical hidden-character findings stop the write and exit 1), `--chatmode`, `--dry-run`, `--no-links`, `--watch`, `--validate`, `--single-agents`, `-v` verbose, `--local-only`, `--clean`, `--with-constitution/--no-constitution`, `--force-instructions` / `--no-dedup` (opt out of Claude/Copilot deduplication), `--root DIR` redirect generated artifacts under DIR while sources resolve from `$PWD` (mirrors `pip install --target`; not valid with `--watch`) |

`apm compile --watch` live-reloads `apm.yml`: editing `target:` / `targets:` mid-session takes effect on the next file event without restarting the watcher. The CLI `--target` flag, when passed to `apm compile --watch`, still outranks `apm.yml`. Re-resolution is gated on the changed file's basename being `apm.yml`, so `.instructions.md` edits do not pay an extra resolver round-trip and a stray `backup_apm.yml` cannot trigger a reload. `--clean` is ignored in watch mode and the watcher prints an explicit `[!]` warning at startup (`--clean is ignored in watch mode; run 'apm compile --clean' separately to remove orphaned outputs.`); run `apm compile --clean` separately between watch sessions to remove orphans.

Expand Down Expand Up @@ -239,7 +239,7 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning,
| `apm config unset KEY` | Remove a stored config value (`target`, `self-update.channel`, `self-update.install-dir`, `temp-dir`, `allow-protocol-fallback`, `prefer-ssh`, `copilot-cowork-skills-dir`, `mcp-registry-url`) | -- |
| `apm lock` | Resolve all dependencies in `apm.yml` and write `apm.lock.yaml` **without** deploying any files to agent targets. Mirrors `cargo generate-lockfile` / `pnpm lock`. Use to bootstrap or refresh the lockfile before reviewing and applying changes. | `--update` re-resolve to latest SHAs, `--verbose`, `-g/--global`, `--no-policy`, `--target` (comma-separated), `--parallel-downloads N` |
| `apm lock export` | Export an SBOM/inventory from the **existing** `apm.lock.yaml` -- reads the lockfile only (no re-resolve, no re-hash, no network). Emits component identity (purl), recorded hashes, and the declared license. Output is deterministic (components sorted by purl, pinned timestamp) for byte-identical reproducibility. This is an inventory export, not a security attestation. | `-f/--format [cyclonedx\|spdx]` (default `cyclonedx`), `-o/--output FILE` (default stdout), `-g/--global` read user-scope lockfile, `--timestamp ISO8601` pin the document timestamp (falls back to `SOURCE_DATE_EPOCH`, then the lockfile's `generated_at`) |
| `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against the latest refs, prints a structured plan (added/updated/removed/unchanged), and prompts before mutating anything (default `[y/N]`). Full-SHA pins are resolved against the latest annotated semver tag, rewritten to that tag's SHA, and annotated as `# <tag>` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Strict superset of the deprecated `apm deps update`. Skips the prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) |
| `apm update [PKGS...]` | Refresh APM dependencies: resolves `apm.yml` against the latest refs, prints a structured plan (added/updated/removed/unchanged), and prompts before mutating anything (default `[y/N]`). Full-SHA pins are resolved against the latest annotated semver tag, rewritten to that tag's SHA, and annotated as `# <tag>` in `apm.yml`. Pass `[PKGS...]` to refresh only those deps, or `-g` for user scope (`~/.apm/`). Successful no-op updates still reconcile deployed artifacts and lockfile ownership when the declared target set contracts. Strict superset of the deprecated `apm deps update`. Skips the prompt with `--yes`; previews with `--dry-run`. | `--yes`, `--dry-run`, `--verbose`, `-g/--global`, `--force`, `--parallel-downloads N`, `--target` (comma-separated) |
| `apm self-update` | Update the APM CLI itself (or show distributor guidance when self-update is disabled at build time). | `--check` only check |

`apm config set prefer-ssh true` and `apm config set allow-protocol-fallback true` persist transport preferences to `~/.apm/config.json` so SSH-only and corporate GHES users no longer need to re-pass `--ssh` / `--allow-protocol-fallback` on every `apm install`. Resolution order: CLI flag > `APM_GIT_PROTOCOL` / `APM_ALLOW_PROTOCOL_FALLBACK` env var > `apm config` value > built-in default (`false`). `apm config unset prefer-ssh` and `apm config unset allow-protocol-fallback` remove the persisted value. In `apm config` / `apm config list` / `apm config get` (no key), the two transport rows surface only when they have been enabled (the `false`-default rows are suppressed to keep the output noise-free); `apm config get <key>` always returns the effective value. Setting `allow-protocol-fallback=true` while `CI=1` emits a warning because the persisted value affects every subsequent `apm install` on a shared `$HOME`; prefer the env var in CI.
Expand Down
11 changes: 11 additions & 0 deletions src/apm_cli/commands/compile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,17 @@ def _coerce_provenance_targets(value):
perf_stats.render_summary(logger, project_root=str(_src))
sys.exit(1)

if result.success and not dry_run:
from ...install.manifest_reconcile import reconcile_project_deployed_state

reconcile_project_deployed_state(
Path(_src).resolve(),
explicit_target=target,
deploy_root=Path(".").resolve(),
lock_root=Path(".").resolve(),
verbose=verbose,
)

perf_stats.render_summary(logger, project_root=str(_src))


Expand Down
13 changes: 13 additions & 0 deletions src/apm_cli/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,19 @@ def _plan_callback(plan: UpdatePlan) -> bool:
if plan is None or not isinstance(plan, UpdatePlan):
return

reconcile_noop = not dry_run and not plan.has_changes and not revision_pin_updates
if plan_state.proceeded or reconcile_noop:
from apm_cli.install.manifest_reconcile import reconcile_project_deployed_state

reconcile_project_deployed_state(
Path.cwd(),
explicit_target=target,
deploy_root=_mcp_lsp_project_root,
lock_root=_apm_dir,
user_scope=scope is InstallScope.USER,
verbose=verbose,
)

if plan_state.proceeded:
if revision_pin_updates:
try:
Expand Down
207 changes: 207 additions & 0 deletions src/apm_cli/install/manifest_reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,13 @@
from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from apm_cli.deps.lockfile import LockFile
from apm_cli.integration.targets import TargetProfile
from apm_cli.utils.diagnostics import DiagnosticCollector


def install_governance(targets: list[TargetProfile]) -> tuple[set[str], set[str]]:
Expand Down Expand Up @@ -170,3 +173,207 @@ def union_preserving(
if prior_hashes and path in prior_hashes:
merged_hashes[path] = prior_hashes[path]
return list(current_files or ()) + preserved, merged_hashes


def declared_target_profiles(
project_root: Path,
*,
user_scope: bool = False,
) -> list[TargetProfile] | None:
"""Resolve the target universe declared by a project manifest."""
from apm_cli.core.apm_yml import CANONICAL_TARGETS, parse_targets_field
from apm_cli.core.errors import TargetResolutionError
from apm_cli.integration.targets import KNOWN_TARGETS
from apm_cli.utils.yaml_io import load_yaml

try:
data = load_yaml(project_root / "apm.yml")
except (AttributeError, KeyError, OSError, TypeError, ValueError):
return None
if not isinstance(data, dict):
return None
try:
names = parse_targets_field(data)
except TargetResolutionError:
return None
if not names:
return None

profiles: list[TargetProfile] = []
for name in dict.fromkeys(names):
profile = KNOWN_TARGETS.get(name)
if profile is None:
continue
scoped = profile.for_scope(user_scope=user_scope)
if scoped is not None:
profiles.append(scoped)
for name, profile in KNOWN_TARGETS.items():
if name in CANONICAL_TARGETS:
continue
scoped = profile.for_scope(user_scope=user_scope)
profiles.append(scoped if scoped is not None else profile)
return profiles or None


def reconcile_deployed_block(
*,
project_root: Path,
dep_key: str,
current_files: list[str],
current_hashes: dict[str, str],
prior_files: list[str],
prior_hashes: dict[str, str],
active_targets: list[TargetProfile],
declared_targets: list[TargetProfile] | None,
diagnostics: DiagnosticCollector,
on_ghost_drop: Callable[[str], None] | None = None,
) -> tuple[list[str], dict[str, str]]:
"""Reconcile one deployed-state block and safely remove dropped paths."""
files, hashes = union_preserving(
current_files,
current_hashes,
prior_files,
prior_hashes,
active_targets,
declared_targets=declared_targets,
on_ghost_drop=on_ghost_drop,
)
dropped = set(prior_files) - set(files)
if not dropped:
return files, hashes

from apm_cli.integration.base_integrator import BaseIntegrator
from apm_cli.integration.cleanup import remove_stale_deployed_files

cleanup = remove_stale_deployed_files(
dropped,
project_root,
dep_key=dep_key,
targets=None,
diagnostics=diagnostics,
recorded_hashes=prior_hashes,
)
for path in cleanup.failed:
if path not in files:
files.append(path)
if path in prior_hashes:
hashes[path] = prior_hashes[path]
if cleanup.deleted_targets:
BaseIntegrator.cleanup_empty_parents(cleanup.deleted_targets, project_root)
return files, hashes


def reconcile_deployed_state(
*,
project_root: Path,
lockfile: LockFile,
active_targets: list[TargetProfile],
declared_targets: list[TargetProfile] | None,
diagnostics: DiagnosticCollector,
) -> bool:
"""Prune undeclared-target ownership from every lockfile deployment block."""
from apm_cli.deps.lockfile import _SELF_KEY
from apm_cli.integration.targets import KNOWN_TARGETS

allowed_targets = [*active_targets, *(declared_targets or [])]
allowed_prefixes, allowed_schemes = install_governance(allowed_targets)
known_prefixes, known_schemes = install_governance(list(KNOWN_TARGETS.values()))

def _retained(files: list[str]) -> list[str]:
if declared_targets is None:
return list(files)
return [
path
for path in files
if not (
is_governed_by_install(path, known_prefixes, known_schemes)
and not is_governed_by_install(path, allowed_prefixes, allowed_schemes)
)
]

changed = False
for dep_key, dependency in lockfile.dependencies.items():
if dep_key == _SELF_KEY:
continue
prior_files = list(dependency.deployed_files)
prior_hashes = dict(dependency.deployed_file_hashes)
current_files = _retained(prior_files)
current_hashes = {
path: value for path, value in prior_hashes.items() if path in current_files
}
files, hashes = reconcile_deployed_block(
project_root=project_root,
dep_key=dep_key,
current_files=current_files,
current_hashes=current_hashes,
prior_files=prior_files,
prior_hashes=prior_hashes,
active_targets=active_targets,
declared_targets=declared_targets,
diagnostics=diagnostics,
)
if files != prior_files or hashes != prior_hashes:
dependency.deployed_files = files
dependency.deployed_file_hashes = hashes
changed = True

prior_local = list(lockfile.local_deployed_files)
prior_local_hashes = dict(lockfile.local_deployed_file_hashes)
current_local = _retained(prior_local)
current_local_hashes = {
path: value for path, value in prior_local_hashes.items() if path in current_local
}
local_files, local_hashes = reconcile_deployed_block(
project_root=project_root,
dep_key="<local .apm/>",
current_files=current_local,
current_hashes=current_local_hashes,
prior_files=prior_local,
prior_hashes=prior_local_hashes,
active_targets=active_targets,
declared_targets=declared_targets,
diagnostics=diagnostics,
)
if local_files != prior_local or local_hashes != prior_local_hashes:
lockfile.local_deployed_files = local_files
lockfile.local_deployed_file_hashes = local_hashes
changed = True
return changed


def reconcile_project_deployed_state(
manifest_root: Path,
*,
explicit_target: str | list[str] | None,
deploy_root: Path | None = None,
lock_root: Path | None = None,
user_scope: bool = False,
verbose: bool = False,
) -> bool:
"""Reconcile and persist a project's deployed state after a command."""
from apm_cli.deps.lockfile import LockFile, get_lockfile_path
from apm_cli.integration.targets import active_targets, active_targets_user_scope
from apm_cli.utils.diagnostics import DiagnosticCollector

deploy_root = deploy_root or manifest_root
lock_path = get_lockfile_path(lock_root or manifest_root)
lockfile = LockFile.read(lock_path)
if lockfile is None:
return False
declared = declared_target_profiles(manifest_root, user_scope=user_scope)
if explicit_target is None and declared is not None:
targets = declared
elif user_scope:
targets = active_targets_user_scope(explicit_target=explicit_target)
else:
targets = active_targets(deploy_root, explicit_target=explicit_target)
changed = reconcile_deployed_state(
project_root=deploy_root,
lockfile=lockfile,
active_targets=targets,
declared_targets=declared,
diagnostics=DiagnosticCollector(verbose=verbose),
)
if changed:
lockfile.save(lock_path)
return changed
22 changes: 15 additions & 7 deletions src/apm_cli/install/phases/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,17 @@ def _attach_deployed_files(self, lockfile: LockFile) -> None:
committed lockfile and they stay covered by the audit gates (issue
#1716). See :mod:`apm_cli.install.manifest_reconcile`.
"""
from apm_cli.install.manifest_reconcile import union_preserving
from apm_cli.install.manifest_reconcile import reconcile_deployed_block
from apm_cli.install.phases.targets import declared_target_profiles

self._reconcile_cross_package_deployed_files()
existing = self.ctx.existing_lockfile
declared = declared_target_profiles(self.ctx)
diagnostics = getattr(self.ctx, "diagnostics", None)
if diagnostics is None:
from apm_cli.utils.diagnostics import DiagnosticCollector

diagnostics = DiagnosticCollector()
ghost_count = 0
for dep_key, locked_dep in lockfile.dependencies.items():
current = list(self.ctx.package_deployed_files.get(dep_key, []))
Expand All @@ -240,13 +245,16 @@ def _log_ghost_drop(path: str, package_key: str = dep_key) -> None:
f"(target not declared in apm.yml) for {package_key}"
)

files, hashes = union_preserving(
current,
current_hashes,
prior_files,
prior_hashes,
self.ctx.targets,
files, hashes = reconcile_deployed_block(
project_root=self.ctx.project_root,
dep_key=dep_key,
current_files=current,
current_hashes=current_hashes,
prior_files=prior_files,
prior_hashes=prior_hashes,
active_targets=self.ctx.targets,
declared_targets=declared,
diagnostics=diagnostics,
on_ghost_drop=_log_ghost_drop,
)
if not files:
Expand Down
Loading
Loading