Skip to content

Bug: apm pack --target claude produces empty bundle when skills installed to .github/ #425

@danielmeppiel

Description

@danielmeppiel

Bug Description

apm pack --target claude produces an empty bundle when plugins/skills are installed by APM, so no skills reach the Claude Code agent runtime. The agent starts with zero marketplace skills/plugins.

Root Cause

Two compounding issues in the install -> pack pipeline:

Issue 1: Install deploys skills to .github/ only when .claude/ does not exist

In skill_integrator.py (line 746), the Claude copy is gated by:

if claude_dir.exists() and claude_dir.is_dir():
    # copy to .claude/skills/

In install.py (line 1264), when neither .github/ nor .claude/ exists, only .github/ is auto-created -- so the target detects as "vscode" and skills only go to .github/skills/.

Issue 2: Pack strictly filters by .claude/ prefix

In lockfile_enrichment.py (line 13):

_TARGET_PREFIXES = {
    "claude": [".claude/"],
    ...
}

So pack --target claude sees the lockfile deployed_files (all .github/* paths) and filters every single one out -> empty bundle.

Resulting scenario

  1. Fresh project with no .github/ or .claude/
  2. apm install -> auto-creates .github/, detects target "vscode"
  3. Skills deploy to .github/skills/ only (.claude/ does not exist)
  4. Lockfile records: deployed_files: [".github/skills/my-plugin/", ...]
  5. apm pack --target claude filters for .claude/ prefix -> 0 files -> empty bundle

This means any Claude-targeted CI pipeline that runs apm install then apm pack --target claude in a clean checkout (the exact apm-action/GH-AW flow) will always produce an empty bundle for skill-only packages.

Reproduction

"""Reproduce: skill installed to .github/skills/ only, apm pack --target claude yields empty bundle."""
import tempfile, yaml
from pathlib import Path
from apm_cli.bundle.packer import pack_bundle
from apm_cli.deps.lockfile import LockFile, LockedDependency

with tempfile.TemporaryDirectory() as td:
    project = Path(td) / "project"
    project.mkdir()

    deployed_files = [
        ".github/skills/my-marketplace-plugin/",
        ".github/skills/my-marketplace-plugin/SKILL.md",
        ".github/skills/my-marketplace-plugin/scripts/do-thing.sh",
    ]

    (project / "apm.yml").write_text(
        yaml.dump({"name": "my-project", "version": "1.0.0"}), encoding="utf-8"
    )

    for fpath in deployed_files:
        full = project / fpath
        if fpath.endswith("/"):
            full.mkdir(parents=True, exist_ok=True)
        else:
            full.parent.mkdir(parents=True, exist_ok=True)
            full.write_text(f"content of {fpath}", encoding="utf-8")

    lockfile = LockFile()
    dep = LockedDependency(
        repo_url="internal-org/marketplace-plugin",
        resolved_commit="abc123",
        deployed_files=deployed_files,
    )
    lockfile.add_dependency(dep)
    lockfile.write(project / "apm.lock.yaml")

    result = pack_bundle(project, Path(td) / "build", target="claude", dry_run=True)
    print(f"Files in bundle: {result.files}")  # [] -- BUG

Proposed Fix: Cross-Target Path Mapping at Pack Time

Why this approach

Alternative Why not
A: Install to all targets always Pollutes projects with unwanted .claude/, .cursor/ dirs
B: Pack re-derives from lockfile Same as E but less precise naming
C: Target-neutral lockfile paths Breaking lockfile format change
D: Read apm_modules directly Bypasses security scanning and integration pipeline
E: Cross-target mapping (chosen) Minimal changes, backward compatible, no lockfile break

Core concept: equivalent path mapping

Skills and agents are semantically identical across targets -- .github/skills/X and .claude/skills/X contain the same content. Pack learns this equivalence:

_CROSS_TARGET_MAPS = {
    "claude":   {".github/skills/": ".claude/skills/",   ".github/agents/": ".claude/agents/"},
    "vscode":   {".claude/skills/": ".github/skills/",   ".claude/agents/": ".github/agents/"},
    "cursor":   {".github/skills/": ".cursor/skills/",   ".github/agents/": ".cursor/agents/"},
    "opencode": {".github/skills/": ".opencode/skills/",  ".github/agents/": ".opencode/agents/"},
}

Only skills/ and agents/ are mapped -- commands, instructions, hooks are target-specific and should NOT be mapped.

Algorithm in _filter_files_by_target()

  1. Collect direct matches (files already under target prefix)
  2. For non-matching files, check if a cross-map rule applies
  3. Apply mapping, dedup against direct matches
  4. Return (filtered_files, path_mappings_dict)

Implementation Plan

1. lockfile_enrichment.py -- Add cross-target mapping

  • Add _CROSS_TARGET_MAPS table
  • Modify _filter_files_by_target() to return Tuple[List[str], Dict[str, str]] (filtered files + mapping dict)
  • When target filter returns empty direct matches, apply cross-map rules
  • Mapped paths replace originals in the returned list

2. packer.py -- Update pack_bundle for mapped paths

  • Update PackResult dataclass: add mapped_count: int and path_mappings: Dict[str, str]
  • Update pack_bundle() to use new filter signature
  • When copying mapped files: read from original path on disk, write to mapped path in bundle
  • Pass mapping info to enrich_lockfile_for_pack() so bundled lockfile uses mapped paths
  • Add mapped_from field to pack section when mapping occurred

3. pack.py -- Improve command UX output

  • Show [i] Mapped N file(s): .github/ -> .claude/ when mapping occurs
  • Verbose: show per-file original -> mapped detail
  • Better empty-bundle messaging: distinguish "no deps" vs "no content" vs "wrong target"
  • Dry-run: show mapping preview before file list

4. lockfile_enrichment.py -- Enriched lockfile uses mapped paths

  • enrich_lockfile_for_pack() rewrites deployed_files using mapped paths
  • Add mapped_from to pack: section when mapping occurred
  • Bundle lockfile is self-consistent: paths match physical file structure

5. Tests

  • .github/skills/X mapped to .claude/skills/X when target=claude
  • Direct .claude/ files are NOT double-mapped
  • Mixed scenario (some direct, some mapped)
  • Dedup when both .github/skills/X and .claude/skills/X exist
  • Empty bundle after mapping still produces good error message
  • Dry-run output includes mapping info
  • Enriched lockfile in bundle has mapped paths
  • PackResult.mapped_count and path_mappings are correct
  • Reverse mapping (claude -> vscode)

6. Documentation

  • Update docs/src/content/docs/guides/pack-distribute.md with cross-target mapping behavior
  • Update troubleshooting section (empty bundle -> now auto-resolved)

7. CI validation

  • Verify existing test_ghaw_compat() in scripts/test-release-validation.sh passes
  • Consider adding bundle file-count check to the test

UX Design

Scenario Output
Normal pack (files match target) [*] Packed N file(s) -> path
Pack with cross-target mapping [i] Mapped N file(s): .github/ -> .claude/ then [*] Packed N file(s) -> path
Truly empty bundle (no content) [!] No files to pack for target 'claude' + hint with available prefixes
Dry-run with mapping [i] [dry-run] Would remap N file(s) + file tree

Key UX decisions:

  • Mapping is visible ([i] info) but not a warning -- it is correct behavior
  • Verbose mode shows per-file original -> mapped detail
  • Bundle lockfile uses mapped paths (self-consistent with on-disk structure)
  • Unpack needs NO changes -- bundle already contains correct paths

Dependency Order

add-cross-target-mapping
  |-> update-packer (depends on new filter signature)
  |     |-> update-pack-cmd-output (depends on PackResult changes)
  |     |-> update-lockfile-enrichment (depends on mapping data flow)
  |-> add-tests (can start in parallel, finalize after impl)
update-docs (after implementation complete)
update-ci-test (after implementation complete)

Risk Assessment

  • Backward compat: Existing lockfiles work -- mapping is additive
  • Plugin format: Unaffected -- reads .apm/ sources directly, never uses deployed_files
  • Unpack: Unaffected -- bundles are self-consistent
  • Performance: Mapping is O(N) over deployed_files -- negligible

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions