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
- Fresh project with no
.github/ or .claude/
apm install -> auto-creates .github/, detects target "vscode"
- Skills deploy to
.github/skills/ only (.claude/ does not exist)
- Lockfile records:
deployed_files: [".github/skills/my-plugin/", ...]
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()
- Collect direct matches (files already under target prefix)
- For non-matching files, check if a cross-map rule applies
- Apply mapping, dedup against direct matches
- 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
Bug Description
apm pack --target claudeproduces 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 existIn
skill_integrator.py(line 746), the Claude copy is gated by: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/prefixIn
lockfile_enrichment.py(line 13):So
pack --target claudesees the lockfile deployed_files (all.github/*paths) and filters every single one out -> empty bundle.Resulting scenario
.github/or.claude/apm install-> auto-creates.github/, detects target"vscode".github/skills/only (.claude/does not exist)deployed_files: [".github/skills/my-plugin/", ...]apm pack --target claudefilters for.claude/prefix -> 0 files -> empty bundleThis means any Claude-targeted CI pipeline that runs
apm installthenapm pack --target claudein a clean checkout (the exact apm-action/GH-AW flow) will always produce an empty bundle for skill-only packages.Reproduction
Proposed Fix: Cross-Target Path Mapping at Pack Time
Why this approach
.claude/,.cursor/dirsCore concept: equivalent path mapping
Skills and agents are semantically identical across targets --
.github/skills/Xand.claude/skills/Xcontain the same content. Pack learns this equivalence:Only
skills/andagents/are mapped -- commands, instructions, hooks are target-specific and should NOT be mapped.Algorithm in
_filter_files_by_target()(filtered_files, path_mappings_dict)Implementation Plan
1.
lockfile_enrichment.py-- Add cross-target mapping_CROSS_TARGET_MAPStable_filter_files_by_target()to returnTuple[List[str], Dict[str, str]](filtered files + mapping dict)2.
packer.py-- Update pack_bundle for mapped pathsPackResultdataclass: addmapped_count: intandpath_mappings: Dict[str, str]pack_bundle()to use new filter signatureenrich_lockfile_for_pack()so bundled lockfile uses mapped pathsmapped_fromfield to pack section when mapping occurred3.
pack.py-- Improve command UX output[i] Mapped N file(s): .github/ -> .claude/when mapping occursoriginal -> mappeddetail4.
lockfile_enrichment.py-- Enriched lockfile uses mapped pathsenrich_lockfile_for_pack()rewritesdeployed_filesusing mapped pathsmapped_fromtopack:section when mapping occurred5. Tests
.github/skills/Xmapped to.claude/skills/Xwhen target=claude.claude/files are NOT double-mapped.github/skills/Xand.claude/skills/XexistPackResult.mapped_countandpath_mappingsare correct6. Documentation
docs/src/content/docs/guides/pack-distribute.mdwith cross-target mapping behavior7. CI validation
test_ghaw_compat()inscripts/test-release-validation.shpassesUX Design
[*] Packed N file(s) -> path[i] Mapped N file(s): .github/ -> .claude/then[*] Packed N file(s) -> path[!] No files to pack for target 'claude'+ hint with available prefixes[i] [dry-run] Would remap N file(s)+ file treeKey UX decisions:
[i]info) but not a warning -- it is correct behaviororiginal -> mappeddetailDependency Order
Risk Assessment
.apm/sources directly, never usesdeployed_files