diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a85bf01..daf9fd61 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/docs/src/content/docs/integrations/ide-tool-integration.md b/docs/src/content/docs/integrations/ide-tool-integration.md index 665511db..fbb7b3a0 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 diff --git a/src/apm_cli/install/phases/download.py b/src/apm_cli/install/phases/download.py index 3c3b7f41..b7901f9e 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 329099f9..7ba36974 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 2a73c7dd..8283e873 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 a27ecda3..8fc970d3 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -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}" + ) + + +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) + + assert not fallback_triggered, ( + "Fallback must not trigger when content_hash is None" + )