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

## [Unreleased]

## [0.12.0] - 2026-05-03

### Added

- **`--target agent-skills` deploys skills to `.agents/skills/` (cross-client shared directory).** The new target writes `SKILL.md` files to the [agentskills.io](https://agentskills.io) standard location without tying them to a single client. Excluded from `--target all` (explicit opt-in only); combine with `--target all,agent-skills` for both. Deduplicates with Codex when both targets resolve to the same path. User-scope (`-g`) deploys to `~/.agents/skills/`. (closes #737)
Expand Down Expand Up @@ -43,7 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **`apm install` now anchors transitive `local_path` deps on the declaring package's directory (npm/pip/cargo parity).** Sibling/monorepo layouts (e.g. `../base` declared inside `packages/specialized/apm.yml`) now resolve relative to the declaring package, not the consumer's project root. **Security tightening:** remote-cloned packages can no longer declare `local_path` deps -- both relative and absolute paths are rejected at `ERROR` severity at resolve time. (#1111, closes #857) Thanks @JahanzaibTayyab.
- `apm compile` no longer silently drops instructions without an `applyTo` pattern from generated `AGENTS.md` and `CLAUDE.md`; globals now render under a `## Global Instructions` section, matching the optimizer's existing `(global)` placement (#1088, closes #1072)
- `apm install` no longer masks local-bundle install failures with `UnboundLocalError`. (#PR_NUMBER)
- `apm install` no longer masks local-bundle install failures with `UnboundLocalError`. (#1108)
- **`apm install <pkg>@<marketplace>` no longer fails for all marketplace packages.** The install resolver now accepts both legacy and current marketplace key names: `repository`/`repo` for github sources, `url`/`repo` for git-subdir sources, and `type`/`source` as the source-type discriminator. A scheme guard rejects full URLs passed through the `url` fallback. (#1106, closes #1105)
- **`apm install --update` no longer fails for GHES/generic hosts** that rely on git credential helpers (e.g., `git-credential-manager`) for authentication. The preflight auth probe was blocking credential helpers by setting `GIT_CONFIG_GLOBAL=/dev/null`; it now uses the same relaxed environment as the clone fallback path for non-GitHub/non-ADO hosts. (#1082)
- `apm compile --dry-run -t copilot` now faithfully simulates the hand-authored file guard: a `.github/copilot-instructions.md` lacking the APM marker is reported as `skipped=1` (matching the real run) instead of as `generated=1`. Previously dry-run would claim a write that a real run would refuse, giving CI preview gates a false signal. (#1048)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "apm-cli"
version = "0.11.0"
version = "0.12.0"
description = "MCP configuration tool"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
38 changes: 37 additions & 1 deletion scripts/test-integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,43 @@ run_e2e_tests() {
log_error "Agent-skills target E2E tests failed!"
exit 1
fi


# Run unified pack format E2E tests -- offline, no tokens needed
# Guards the 0.12.0 default flip from --format apm to --format plugin.
log_info "Running unified pack format E2E tests..."
echo "Command: pytest tests/integration/test_pack_unified.py -v -s --tb=short"

if pytest tests/integration/test_pack_unified.py -v -s --tb=short; then
log_success "Unified pack format E2E tests passed!"
else
log_error "Unified pack format E2E tests failed!"
exit 1
fi

# Run Copilot compile target E2E tests -- offline, no tokens needed
# Guards .github/copilot-instructions.md generation + idempotent cleanup.
log_info "Running Copilot compile target E2E tests..."
echo "Command: pytest tests/integration/test_compile_copilot_root_instructions.py -v -s --tb=short"

if pytest tests/integration/test_compile_copilot_root_instructions.py -v -s --tb=short; then
log_success "Copilot compile target E2E tests passed!"
else
log_error "Copilot compile target E2E tests failed!"
exit 1
fi

# Run transitive local-path chain E2E tests -- offline, no tokens needed
# Guards local_path anchoring across multi-level local dependency chains.
log_info "Running transitive local-path chain E2E tests..."
echo "Command: pytest tests/integration/test_transitive_chain_e2e.py -v -s --tb=short"

if pytest tests/integration/test_transitive_chain_e2e.py -v -s --tb=short; then
log_success "Transitive local-path chain E2E tests passed!"
else
log_error "Transitive local-path chain E2E tests failed!"
exit 1
fi

log_success "All integration test suites completed successfully!"


Expand Down
17 changes: 15 additions & 2 deletions src/apm_cli/install/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,8 @@ def integrate_local_bundle(
import hashlib
import shutil

from apm_cli.utils.content_hash import compute_file_hash

from ..core.scope import InstallScope
from ..utils.path_security import (
PathTraversalError,
Expand Down Expand Up @@ -490,7 +492,13 @@ def integrate_local_bundle(

if dry_run:
deployed_files.append(record)
deployed_hashes[record] = expected_hash
# Normalize to "sha256:<hex>" so the dry-run lockfile preview
# matches the format written by ``compute_file_hash`` on the
# real deploy path. ``expected_hash`` here is bare hex from
# ``pack.bundle_files``; without the prefix, downstream
# exact-match comparisons (e.g. ``cleanup.py`` provenance
# check) treat the file as user-edited and skip cleanup.
deployed_hashes[record] = f"sha256:{expected_hash}"
if logger:
logger.verbose_detail(f"[dry-run] would deploy {record}")
continue
Expand Down Expand Up @@ -521,7 +529,12 @@ def integrate_local_bundle(
# provenance now keeps the lockfile honest if future transforms
# (frontmatter injection, etc.) mutate content during deploy.
deployed_files.append(record)
deployed_hashes[record] = hashlib.sha256(dest.read_bytes()).hexdigest()
# Use ``compute_file_hash`` so the recorded value carries the
# canonical ``sha256:<hex>`` prefix. Matches the format written
# by the regular install pipeline (``compute_deployed_hashes``)
# so subsequent stale-cleanup provenance checks compare equal
# instead of mis-classifying these files as user-edited.
deployed_hashes[record] = compute_file_hash(dest)
if logger:
logger.verbose_detail(f"deployed {record}")

Expand Down
12 changes: 11 additions & 1 deletion src/apm_cli/integration/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,17 @@ def remove_stale_deployed_files(
package=dep_key,
)
continue
if actual_hash != expected_hash:

# Defensive normalization: ``recorded_hashes`` may carry either
# the canonical ``sha256:<hex>`` (regular install pipeline) or
# bare ``<hex>`` (legacy local-bundle installs prior to the
# 0.12.0 fix). ``compute_file_hash`` always returns the
# prefixed form, so strip the prefix from both sides before
# comparing to avoid false "user-edited" classifications.
def _strip_sha256(h: str) -> str:
return h[len("sha256:") :] if h.startswith("sha256:") else h

if _strip_sha256(actual_hash) != _strip_sha256(expected_hash):
result.skipped_user_edit.append(stale_path)
diagnostics.warn(
(
Expand Down
113 changes: 111 additions & 2 deletions tests/integration/test_install_local_bundle_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,17 +491,126 @@ def test_local_lockfile_records_deployed_files(
# Every recorded path must have a matching hash entry.
assert set(deployed_files) == set(deployed_hashes.keys())

# Each recorded path's hash must match the on-disk file's actual SHA-256.
# Each recorded path's hash must match the on-disk file's actual
# SHA-256, written in the canonical ``sha256:<hex>`` form so it
# compares equal against ``compute_file_hash`` output (regression
# guard: prior to 0.12.0 the local-bundle path wrote bare hex,
# which mis-classified files as "user-edited" in stale-cleanup).
for record_path, expected_hash in deployed_hashes.items():
# Records may be absolute or project-relative; resolve both.
candidate = Path(record_path)
if not candidate.is_absolute():
candidate = project / candidate
assert candidate.is_file(), f"missing deployed file: {candidate}"
actual_hash = hashlib.sha256(candidate.read_bytes()).hexdigest()
assert expected_hash.startswith("sha256:"), (
f"hash for {record_path!r} must use canonical 'sha256:<hex>' "
f"form, got {expected_hash!r}"
)
actual_hash = "sha256:" + hashlib.sha256(candidate.read_bytes()).hexdigest()
assert actual_hash == expected_hash, f"hash mismatch for {candidate}"


# ---------------------------------------------------------------------------
# E2E: Hash-format consistency across install flows (regression for 0.12.0)
# ---------------------------------------------------------------------------


class TestLocalBundleHashFormatCrossFlow:
"""Pin the hash format contract that ties ``apm install <bundle>`` to
the stale-cleanup provenance check.

Prior to the 0.12.0 fix, ``integrate_local_bundle`` wrote bare
``<hex>`` into ``local_deployed_file_hashes`` while
``compute_file_hash`` (used by ``cleanup.py``) emitted the canonical
``sha256:<hex>``. An exact-match comparison in
``remove_stale_deployed_files`` then mis-classified every
bundle-deployed file as "user-edited" and refused to remove stale
entries on subsequent installs.
"""

def test_local_bundle_hash_matches_compute_file_hash_format(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Hash recorded by local-bundle install must equal compute_file_hash output."""
from apm_cli.utils.content_hash import compute_file_hash

bundle = _make_plugin_bundle(tmp_path / "src")
project = _make_project(tmp_path / "dst")

result = _invoke_install(
project, str(bundle), "--target", "copilot", monkeypatch=monkeypatch
)
assert result.exit_code == 0, f"stdout={result.output!r}\nstderr={result.stderr!r}"

data = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8"))
deployed_hashes = data.get("local_deployed_file_hashes") or {}
assert deployed_hashes, "local_deployed_file_hashes is empty"

for record_path, recorded in deployed_hashes.items():
candidate = Path(record_path)
if not candidate.is_absolute():
candidate = project / candidate
# Equality with compute_file_hash is the contract: this is the
# exact comparison cleanup.py uses for stale-file provenance.
assert recorded == compute_file_hash(candidate), (
f"hash format drift for {record_path!r}: "
f"recorded={recorded!r} vs compute_file_hash={compute_file_hash(candidate)!r}. "
"Stale-cleanup provenance check would mis-classify this file as user-edited."
)

def test_recorded_hash_compares_equal_in_cleanup_provenance_check(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Hashes recorded by local-bundle install must NOT trip the
``cleanup.remove_stale_deployed_files`` "user-edited" guard
when the deployed file is unchanged.

This drives the actual code path that the regression broke:
``cleanup.py`` reads ``recorded_hashes`` from the lockfile (set
by ``integrate_local_bundle``), recomputes via
``compute_file_hash``, and compares. Prior to 0.12.0 the
comparison always failed (bare hex vs ``sha256:<hex>``), so
every bundle-deployed file was permanently classified as
user-edited and stale-cleanup was a no-op.
"""
from apm_cli.integration.cleanup import remove_stale_deployed_files
from apm_cli.utils.diagnostics import DiagnosticCollector

bundle = _make_plugin_bundle(tmp_path / "src")
project = _make_project(tmp_path / "dst")
result = _invoke_install(
project, str(bundle), "--target", "copilot", monkeypatch=monkeypatch
)
assert result.exit_code == 0, f"install failed: {result.output}"

data = yaml.safe_load((project / "apm.lock.yaml").read_text(encoding="utf-8"))
deployed_files = list(data.get("local_deployed_files") or [])
deployed_hashes = dict(data.get("local_deployed_file_hashes") or {})
assert deployed_files and deployed_hashes

# Pretend every file is now stale and ask cleanup to remove them.
# The provenance gate should pass (file is unchanged), so cleanup
# actually deletes them -- not skip them as "user-edited".
diagnostics = DiagnosticCollector()
cleanup_result = remove_stale_deployed_files(
deployed_files,
project,
dep_key="<local-bundle-test>",
targets=None,
diagnostics=diagnostics,
recorded_hashes=deployed_hashes,
)

assert not cleanup_result.skipped_user_edit, (
"cleanup mis-classified bundle-deployed files as user-edited: "
f"{cleanup_result.skipped_user_edit}. "
"Likely a hash-format regression between integrate_local_bundle "
"(write side) and compute_file_hash (read side in cleanup.py)."
)
# Every file passed the provenance check and was deleted.
assert set(cleanup_result.deleted) == set(deployed_files)


# ---------------------------------------------------------------------------
# E2E: Air-gap proof (zero network I/O)
# ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading