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

- Harden `apm install` stale-file cleanup to prevent unsafe lockfile deletions, preserve user-edited files via per-file SHA-256 provenance, and improve cleanup reporting during install and `--dry-run` (#666, #762)
- Local `.apm/` stale-cleanup now uses pre-install content hashes for provenance verification. Previously the lockfile was re-read after regeneration, which always yielded empty hashes, causing the user-edit safety gate to be silently skipped for project-local files (#764)
- Fix `apm install --target claude` not creating `.claude/` when the directory does not already exist (`auto_create=False` targets now get their root directory created when explicitly requested) (#763)
- Fix content hash mismatch on re-install when `.git/` is absent from installed packages by falling back to content-hash verification before re-downloading (#763)
- Fix `apm marketplace add` silently failing for private repos by using credentials when probing `marketplace.json` (#701)
- Harden marketplace plugin normalization to enforce that manifest-declared `agents`/`skills`/`commands`/`hooks` paths resolve inside the plugin root (#760)
- Stop `test_auto_detect_through_proxy` from making real `api.github.com` calls by passing a mock `auth_resolver`, fixing flaky macOS CI rate-limit failures (#759)
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/integrations/ide-tool-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ For running agentic workflows locally, see the [Agent Workflows guide](../../gui

APM works natively with VS Code's GitHub Copilot implementation.

> **Auto-Detection**: VS Code integration is automatically enabled when a `.github/` folder exists in your project. If neither `.github/` nor `.claude/` exists, `apm install` skips folder integration (packages are still installed to `apm_modules/`).
> **Auto-Detection**: VS Code integration is automatically enabled when a `.github/` folder exists in your project. If neither `.github/` nor `.claude/` exists, `apm install` skips folder integration (packages are still installed to `apm_modules/`). To force integration regardless of folder presence, pass an explicit target (e.g. `apm install --target copilot`) or set `target:` in `apm.yml` -- the target's root folder will be created automatically.

### Native VS Code Primitives

Expand Down Expand Up @@ -167,7 +167,7 @@ AGENTS.md aggregates instructions, context, and optionally the Spec-kit constitu

APM provides first-class support for Claude Code and Claude Desktop through native format generation.

> **Auto-Detection**: Claude integration is automatically enabled when a `.claude/` folder exists in your project. If neither `.github/` nor `.claude/` exists, `apm install` skips folder integration (packages are still installed to `apm_modules/`).
> **Auto-Detection**: Claude integration is automatically enabled when a `.claude/` folder exists in your project. If neither `.github/` nor `.claude/` exists, `apm install` skips folder integration (packages are still installed to `apm_modules/`). To force integration regardless of folder presence, pass an explicit target (e.g. `apm install --target claude`) or set `target: claude` in `apm.yml` -- `.claude/` will be created automatically.

### Optional: Compiled Output for Claude

Expand Down
8 changes: 7 additions & 1 deletion src/apm_cli/install/phases/download.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,13 @@ def run(ctx: "InstallContext") -> None:
if _PDGitRepo(_pd_path).head.commit.hexsha == _pd_locked_chk.resolved_commit:
continue
except Exception:
pass
# Git check failed (e.g. .git removed after download).
# Fall back to content-hash verification so correctly
# installed packages are not re-downloaded every run (#763).
if _pd_locked_chk.content_hash and _pd_path.is_dir():
from apm_cli.utils.content_hash import verify_package_hash as _pd_verify_hash
if _pd_verify_hash(_pd_path, _pd_locked_chk.content_hash):
continue
# Build download ref (use locked commit for reproducibility).
# build_download_ref() uses the manifest ref when ref_changed is True.
_pd_dlref = build_download_ref(
Expand Down
14 changes: 12 additions & 2 deletions src/apm_cli/install/phases/integrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ def _resolve_download_strategy(
if local_repo.head.commit.hexsha == locked_dep.resolved_commit:
lockfile_match = True
except Exception:
pass # Local checkout invalid -- fall through to download
# Git check failed (e.g. .git removed). Fall back to
# content-hash verification (#763).
if locked_dep.content_hash and install_path.is_dir():
from apm_cli.utils.content_hash import verify_package_hash
if verify_package_hash(install_path, locked_dep.content_hash):
lockfile_match = True
elif not ref_changed:
# Normal mode: compare local HEAD with lockfile SHA.
try:
Expand All @@ -117,7 +122,12 @@ def _resolve_download_strategy(
if local_repo.head.commit.hexsha == locked_dep.resolved_commit:
lockfile_match = True
except Exception:
pass # Not a git repo or invalid -- fall through to download
# Git check failed (e.g. .git removed). Fall back to
# content-hash verification (#763).
if locked_dep.content_hash and install_path.is_dir():
from apm_cli.utils.content_hash import verify_package_hash
if verify_package_hash(install_path, locked_dep.content_hash):
lockfile_match = True
skip_download = install_path.exists() and (
(is_cacheable and not update_refs)
or (already_resolved and not update_refs)
Expand Down
6 changes: 5 additions & 1 deletion src/apm_cli/install/phases/targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ def run(ctx: "InstallContext") -> None:
)

for _t in _targets:
if not _t.auto_create:
# When the user passes --target (or apm.yml sets target=) we honour
# the request even for targets that normally don't auto-create
# their root dir (e.g. claude). Without this, `apm install --target
# claude` would silently no-op when .claude/ doesn't exist (#763).
if not _t.auto_create and not _explicit:
continue
_root = _t.root_dir
_target_dir = ctx.project_root / _root
Expand Down
120 changes: 120 additions & 0 deletions tests/unit/test_install_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -993,3 +993,123 @@ def test_ghes_host_skips_ssh_attempt(self, mock_run):
assert all("git@" not in arg for arg in only_cmd), (
f"Expected HTTPS-only URL for GHES host, got: {only_cmd}"
)


class TestExplicitTargetDirCreation:
"""Verify --target creates root_dir even when auto_create=False (GH bug fix)."""

def setup_method(self):
self._tmpdir = tempfile.mkdtemp()
self.project_root = Path(self._tmpdir)

def teardown_method(self):
import shutil
shutil.rmtree(self._tmpdir, ignore_errors=True)

def test_explicit_target_creates_dir_for_auto_create_false(self):
"""When _explicit is set, target dirs are created even if auto_create=False."""
from apm_cli.integration.targets import KNOWN_TARGETS

claude = KNOWN_TARGETS["claude"]
assert claude.auto_create is False

# Simulate the fixed loop logic: create dir when _explicit is set
_explicit = "claude"
_targets = [claude]
for _t in _targets:
if not _t.auto_create and not _explicit:
continue
_target_dir = self.project_root / _t.root_dir
if not _target_dir.exists():
_target_dir.mkdir(parents=True, exist_ok=True)

assert (self.project_root / ".claude").is_dir()

def test_auto_detect_skips_dir_for_auto_create_false(self):
"""Without _explicit, auto_create=False targets don't get dirs created."""
from apm_cli.integration.targets import KNOWN_TARGETS

claude = KNOWN_TARGETS["claude"]
assert claude.auto_create is False

_explicit = None
_targets = [claude]
for _t in _targets:
if not _t.auto_create and not _explicit:
continue
_target_dir = self.project_root / _t.root_dir
if not _target_dir.exists():
_target_dir.mkdir(parents=True, exist_ok=True)

assert not (self.project_root / ".claude").exists()

def test_auto_create_true_always_creates_dir(self):
"""auto_create=True targets create dir regardless of _explicit."""
from apm_cli.integration.targets import KNOWN_TARGETS

copilot = KNOWN_TARGETS["copilot"]
assert copilot.auto_create is True

for _explicit in [None, "copilot"]:
import shutil
shutil.rmtree(self.project_root / copilot.root_dir, ignore_errors=True)

_targets = [copilot]
for _t in _targets:
if not _t.auto_create and not _explicit:
continue
_target_dir = self.project_root / _t.root_dir
if not _target_dir.exists():
_target_dir.mkdir(parents=True, exist_ok=True)

assert (self.project_root / ".github").is_dir(), (
f"auto_create=True should create dir when _explicit={_explicit!r}"
)
Comment on lines +999 to +1067
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

These tests duplicate the production directory-creation loop inline (re-implementing the _explicit/auto_create condition) instead of exercising apm install/_install_apm_dependencies behavior. This makes the tests brittle (they can pass even if install.py regresses) and doesn't assert the CLI-facing bug fix. Consider invoking the install command via CliRunner with --target claude (mocking downloads/integration as needed) and asserting .claude/ is created, or factoring the loop into a helper and testing that helper directly.

Suggested change
"""Verify --target creates root_dir even when auto_create=False (GH bug fix)."""
def setup_method(self):
self._tmpdir = tempfile.mkdtemp()
self.project_root = Path(self._tmpdir)
def teardown_method(self):
import shutil
shutil.rmtree(self._tmpdir, ignore_errors=True)
def test_explicit_target_creates_dir_for_auto_create_false(self):
"""When _explicit is set, target dirs are created even if auto_create=False."""
from apm_cli.integration.targets import KNOWN_TARGETS
claude = KNOWN_TARGETS["claude"]
assert claude.auto_create is False
# Simulate the fixed loop logic: create dir when _explicit is set
_explicit = "claude"
_targets = [claude]
for _t in _targets:
if not _t.auto_create and not _explicit:
continue
_target_dir = self.project_root / _t.root_dir
if not _target_dir.exists():
_target_dir.mkdir(parents=True, exist_ok=True)
assert (self.project_root / ".claude").is_dir()
def test_auto_detect_skips_dir_for_auto_create_false(self):
"""Without _explicit, auto_create=False targets don't get dirs created."""
from apm_cli.integration.targets import KNOWN_TARGETS
claude = KNOWN_TARGETS["claude"]
assert claude.auto_create is False
_explicit = None
_targets = [claude]
for _t in _targets:
if not _t.auto_create and not _explicit:
continue
_target_dir = self.project_root / _t.root_dir
if not _target_dir.exists():
_target_dir.mkdir(parents=True, exist_ok=True)
assert not (self.project_root / ".claude").exists()
def test_auto_create_true_always_creates_dir(self):
"""auto_create=True targets create dir regardless of _explicit."""
from apm_cli.integration.targets import KNOWN_TARGETS
copilot = KNOWN_TARGETS["copilot"]
assert copilot.auto_create is True
for _explicit in [None, "copilot"]:
import shutil
shutil.rmtree(self.project_root / copilot.root_dir, ignore_errors=True)
_targets = [copilot]
for _t in _targets:
if not _t.auto_create and not _explicit:
continue
_target_dir = self.project_root / _t.root_dir
if not _target_dir.exists():
_target_dir.mkdir(parents=True, exist_ok=True)
assert (self.project_root / ".github").is_dir(), (
f"auto_create=True should create dir when _explicit={_explicit!r}"
)
"""Verify install creates target root dirs through the CLI flow."""
def setup_method(self):
self._tmpdir = tempfile.mkdtemp()
self.project_root = Path(self._tmpdir)
self.runner = CliRunner()
self.original_dir = os.getcwd()
os.chdir(self.project_root)
def teardown_method(self):
import shutil
os.chdir(self.original_dir)
shutil.rmtree(self._tmpdir, ignore_errors=True)
@patch("apm_cli.commands.install._validate_package_exists")
@patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
@patch("apm_cli.commands.install.APMPackage")
@patch("apm_cli.commands.install._install_apm_dependencies")
def test_explicit_target_creates_dir_for_auto_create_false(
self, mock_install_apm, mock_apm_package, mock_validate
):
"""When --target is set, target dirs are created even if auto_create=False."""
mock_validate.return_value = True
mock_pkg_instance = MagicMock()
mock_pkg_instance.get_apm_dependencies.return_value = [
MagicMock(repo_url="test/package", reference="main")
]
mock_pkg_instance.get_mcp_dependencies.return_value = []
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
mock_install_apm.return_value = InstallResult(
diagnostics=MagicMock(
has_diagnostics=False, has_critical_security=False
)
)
result = self.runner.invoke(
cli, ["install", "--target", "claude", "test/package"]
)
assert result.exit_code == 0
assert (self.project_root / ".claude").is_dir()
@patch("apm_cli.commands.install._validate_package_exists")
@patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
@patch("apm_cli.commands.install.APMPackage")
@patch("apm_cli.commands.install._install_apm_dependencies")
def test_auto_detect_skips_dir_for_auto_create_false(
self, mock_install_apm, mock_apm_package, mock_validate
):
"""Without --target, auto_create=False targets do not get dirs created."""
mock_validate.return_value = True
mock_pkg_instance = MagicMock()
mock_pkg_instance.get_apm_dependencies.return_value = [
MagicMock(repo_url="test/package", reference="main")
]
mock_pkg_instance.get_mcp_dependencies.return_value = []
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
mock_install_apm.return_value = InstallResult(
diagnostics=MagicMock(
has_diagnostics=False, has_critical_security=False
)
)
result = self.runner.invoke(cli, ["install", "test/package"])
assert result.exit_code == 0
assert not (self.project_root / ".claude").exists()
@patch("apm_cli.commands.install._validate_package_exists")
@patch("apm_cli.commands.install.APM_DEPS_AVAILABLE", True)
@patch("apm_cli.commands.install.APMPackage")
@patch("apm_cli.commands.install._install_apm_dependencies")
@pytest.mark.parametrize(
"args",
[
["install", "test/package"],
["install", "--target", "copilot", "test/package"],
],
)
def test_auto_create_true_always_creates_dir(
self, mock_install_apm, mock_apm_package, mock_validate, args
):
"""auto_create=True targets create dirs with and without an explicit target."""
import shutil
shutil.rmtree(self.project_root / ".github", ignore_errors=True)
mock_validate.return_value = True
mock_pkg_instance = MagicMock()
mock_pkg_instance.get_apm_dependencies.return_value = [
MagicMock(repo_url="test/package", reference="main")
]
mock_pkg_instance.get_mcp_dependencies.return_value = []
mock_apm_package.from_apm_yml.return_value = mock_pkg_instance
mock_install_apm.return_value = InstallResult(
diagnostics=MagicMock(
has_diagnostics=False, has_critical_security=False
)
)
result = self.runner.invoke(cli, args)
assert result.exit_code == 0
assert (self.project_root / ".github").is_dir()

Copilot uses AI. Check for mistakes.


class TestContentHashFallback:
"""Verify content-hash fallback when .git is removed from installed packages."""

def test_hash_match_skips_redownload(self):
"""Content hash verification allows skipping re-download."""
from apm_cli.utils.content_hash import compute_package_hash, verify_package_hash

with tempfile.TemporaryDirectory() as tmpdir:
pkg_dir = Path(tmpdir) / "pkg"
pkg_dir.mkdir()
(pkg_dir / "file.txt").write_text("hello")
content_hash = compute_package_hash(pkg_dir)

assert verify_package_hash(pkg_dir, content_hash) is True

def test_hash_mismatch_triggers_redownload(self):
"""Mismatched content hash means re-download should proceed."""
from apm_cli.utils.content_hash import verify_package_hash

with tempfile.TemporaryDirectory() as tmpdir:
pkg_dir = Path(tmpdir) / "pkg"
pkg_dir.mkdir()
(pkg_dir / "file.txt").write_text("original")

assert verify_package_hash(pkg_dir, "sha256:badhash") is False

def test_missing_content_hash_skips_fallback(self):
"""When locked dep has no content_hash, the fallback guard prevents
verify_package_hash from being called."""
from apm_cli.utils.content_hash import verify_package_hash

with tempfile.TemporaryDirectory() as tmpdir:
pkg_dir = Path(tmpdir) / "pkg"
pkg_dir.mkdir()
(pkg_dir / "file.txt").write_text("data")

# Simulate the guard logic from install.py:
# if _pd_locked_chk.content_hash and _pd_path.is_dir():
content_hash = None # no content_hash recorded in lockfile
fallback_triggered = False
if content_hash and pkg_dir.is_dir():
fallback_triggered = verify_package_hash(pkg_dir, content_hash)

Comment on lines +1070 to +1112
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

The new content-hash tests validate compute_package_hash/verify_package_hash in isolation, but they don't cover the install-time fallback behavior added in install.py (i.e., .git missing -> git SHA check raises -> fallback hash check prevents a re-download). To prevent regressions, add a test that runs the install flow (or the relevant internal function) with a mocked GitRepo that raises and a mocked downloader, and assert the downloader is not called when the lockfile content_hash matches (and is called when it mismatches or is missing).

Copilot uses AI. Check for mistakes.
assert not fallback_triggered, (
"Fallback must not trigger when content_hash is None"
)
Loading