From dc0c416ba7e46a013e81ab1262b034981e0fdf46 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 19 Apr 2026 08:37:04 +0000
Subject: [PATCH 1/4] Initial plan


From 088ffc11a6e735a156205a78d7e9464241eeda9a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 19 Apr 2026 08:41:57 +0000
Subject: [PATCH 2/4] fix: create target dir for explicit --target even when
 auto_create=False, add content hash fallback when .git is absent

Agent-Logs-Url: https://github.com/microsoft/apm/sessions/b38e9e9f-cf61-41fe-932a-cecd717bdaf8

Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com>
---
 CHANGELOG.md                            |   2 +
 src/apm_cli/install/phases/download.py  |   8 +-
 src/apm_cli/install/phases/integrate.py |  14 ++-
 src/apm_cli/install/phases/targets.py   |   6 +-
 tests/unit/test_install_command.py      | 108 ++++++++++++++++++++++++
 5 files changed, 134 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a85bf017..daf9fd614 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/src/apm_cli/install/phases/download.py b/src/apm_cli/install/phases/download.py
index 3c3b7f41b..b7901f9e7 100644
--- a/src/apm_cli/install/phases/download.py
+++ b/src/apm_cli/install/phases/download.py
@@ -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(
diff --git a/src/apm_cli/install/phases/integrate.py b/src/apm_cli/install/phases/integrate.py
index 329099f92..7ba369746 100644
--- a/src/apm_cli/install/phases/integrate.py
+++ b/src/apm_cli/install/phases/integrate.py
@@ -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:
@@ -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)
diff --git a/src/apm_cli/install/phases/targets.py b/src/apm_cli/install/phases/targets.py
index 2a73c7dd6..8283e8731 100644
--- a/src/apm_cli/install/phases/targets.py
+++ b/src/apm_cli/install/phases/targets.py
@@ -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
diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index a27ecda37..1f88d30f2 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -993,3 +993,111 @@ 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}"
+            )
+
+
+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, fallback is not attempted."""
+        locked = MagicMock()
+        locked.resolved_commit = "abc123"
+        locked.content_hash = None
+
+        # The guard `if locked.content_hash` prevents fallback
+        assert not locked.content_hash

From 06ca5fd7f180db71b01ee7a35dd9ae650100ae10 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 19 Apr 2026 08:43:42 +0000
Subject: [PATCH 3/4] improve test_missing_content_hash_skips_fallback to
 validate guard logic in context

Agent-Logs-Url: https://github.com/microsoft/apm/sessions/b38e9e9f-cf61-41fe-932a-cecd717bdaf8

Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com>
---
 tests/unit/test_install_command.py | 24 ++++++++++++++++++------
 1 file changed, 18 insertions(+), 6 deletions(-)

diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py
index 1f88d30f2..8fc970d33 100644
--- a/tests/unit/test_install_command.py
+++ b/tests/unit/test_install_command.py
@@ -1094,10 +1094,22 @@ def test_hash_mismatch_triggers_redownload(self):
             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, fallback is not attempted."""
-        locked = MagicMock()
-        locked.resolved_commit = "abc123"
-        locked.content_hash = None
+        """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
 
-        # The guard `if locked.content_hash` prevents fallback
-        assert not locked.content_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)
+
+            assert not fallback_triggered, (
+                "Fallback must not trigger when content_hash is None"
+            )

From 1e00ffd2f311b848f3e9f70e2ba1a98753c075cd Mon Sep 17 00:00:00 2001
From: danielmeppiel <dmeppiel@microsoft.com>
Date: Sun, 19 Apr 2026 16:58:41 +0200
Subject: [PATCH 4/4] docs: clarify explicit --target overrides auto-detection
 in install

When a user passes --target (or sets target: in apm.yml), apm install
now creates the target's root folder even when auto_create=False (#763).
Update VS Code and Claude integration auto-detection notes so users
know the explicit-target override exists.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 docs/src/content/docs/integrations/ide-tool-integration.md | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md
index 665511db5..fbb7b3a06 100644
--- a/docs/src/content/docs/integrations/ide-tool-integration.md
+++ b/docs/src/content/docs/integrations/ide-tool-integration.md
@@ -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
 
@@ -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
 
