Skip to content

Commit 154bd56

Browse files
Merge branch 'main' into copilot/fix-windows-path-compatibility
2 parents 7e7e244 + 8c677c6 commit 154bd56

File tree

12 files changed

+768
-57
lines changed

12 files changed

+768
-57
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
- Systematic Windows path compatibility hardening: added `portable_relpath()` utility and migrated ~23 `relative_to()` call sites to use `.resolve()` on both sides + `.as_posix()` output, preventing `ValueError` on Windows 8.3 short names and backslash-contaminated path strings (#419)
1414
- Virtual package types (files, collections, subdirectories) now respect `ARTIFACTORY_ONLY=1`, matching the primary zip-archive proxy-only behavior (#418)
15+
- `apm pack --target claude` no longer produces an empty bundle when skills/agents are installed under `.github/` -- cross-target path mapping remaps `skills/` and `agents/` to the pack target prefix (#420)
1516

1617
### Added
1718

@@ -32,6 +33,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3233
- `gh-aw-compat` is now informational (`continue-on-error: true`) — non-deterministic external dependencies should not block releases (#371)
3334
- Copilot encoding instructions: `encoding.instructions.md` (`applyTo: "**"`) bans non-ASCII characters in source and CLI output; updated `copilot-instructions.md` and `cli.instructions.md` to use ASCII bracket notation (`[+]`/`[!]`/`[x]`/`[i]`/`[*]`/`[>]`) instead of emoji STATUS_SYMBOLS (#282)
3435

36+
### Fixed
37+
38+
- Resolved Windows 8.3 short-name path mismatch: call `.resolve()` on both sides of `relative_to()` in `_generate_placement_summary` and `_generate_distributed_summary` so paths display correctly on Windows CI runners (#411)
39+
3540
## [0.8.4] - 2026-03-22
3641

3742
### Added

docs/src/content/docs/guides/pack-distribute.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Creates a self-contained bundle from installed dependencies. Reads the `deployed
4040
apm pack
4141

4242
# Filter by target
43-
apm pack --target vscode # only .github/ files
43+
apm pack --target copilot # only .github/ files
4444
apm pack --target claude # only .claude/ files
4545
apm pack --target all # both targets
4646

@@ -62,7 +62,7 @@ apm pack --dry-run
6262
| Flag | Default | Description |
6363
|------|---------|-------------|
6464
| `--format` | `apm` | Bundle format (`apm` or `plugin`) |
65-
| `-t, --target` | auto-detect | File filter: `copilot`, `vscode`, `claude`, `cursor`, `opencode`, `all`. `vscode` is an alias for `copilot` |
65+
| `-t, --target` | auto-detect | File filter: `copilot`, `claude`, `cursor`, `opencode`, `all`. `vscode` is a deprecated alias for `copilot` |
6666
| `--archive` | off | Produce `.tar.gz` instead of directory |
6767
| `-o, --output` | `./build` | Output directory |
6868
| `--dry-run` | off | List files without writing |
@@ -75,14 +75,48 @@ The target flag controls which deployed files are included based on path prefix:
7575
| Target | Includes |
7676
|--------|----------|
7777
| `copilot` | Paths starting with `.github/` |
78-
| `vscode` | Alias for `copilot` |
78+
| `vscode` | Deprecated alias for `copilot` |
7979
| `claude` | Paths starting with `.claude/` |
8080
| `cursor` | Paths starting with `.cursor/` |
8181
| `opencode` | Paths starting with `.opencode/` |
8282
| `all` | `.github/`, `.claude/`, `.cursor/`, and `.opencode/` |
8383

8484
When no target is specified, APM auto-detects from the `target` field in `apm.yml`, falling back to `all`.
8585

86+
### Cross-target path mapping
87+
88+
Skills and agents are semantically identical across targets -- `.github/skills/X` and `.claude/skills/X` contain the same content. When the lockfile records files under a different target prefix than the one you are packing for, APM automatically remaps `skills/` and `agents/` paths:
89+
90+
```
91+
apm pack --target claude
92+
# .github/skills/my-plugin/SKILL.md -> .claude/skills/my-plugin/SKILL.md
93+
# .github/agents/helper.md -> .claude/agents/helper.md
94+
```
95+
96+
Only `skills/` and `agents/` are remapped. Commands, instructions, and hooks are target-specific and are never mapped.
97+
98+
The enriched lockfile inside the bundle uses the remapped paths, so the bundle is self-consistent. When mapping occurs, the `pack:` section includes a `mapped_from` field listing the original prefixes.
99+
100+
### Targeting mental model
101+
102+
**Choose your target when you pack. Unpack delivers exactly what was packed.**
103+
104+
A bundle is a deployable snapshot, not a retargetable source artifact. Target selection happens at pack time because that is when the full context is available -- which file types are remappable (skills, agents) and which are target-specific (commands, instructions, hooks).
105+
106+
`apm unpack` does not remap paths. If the bundle was packed for Claude, the files land under `.claude/`. If you need a different target, re-pack from source with the desired `--target` flag, or use `--target all` to include all platforms.
107+
108+
When unpacking, APM reads the bundle's `pack:` metadata and shows the target it was packed for. If the bundle target does not match the project's detected target, a warning is displayed:
109+
110+
```
111+
$ apm unpack team-skills.tar.gz
112+
[*] Unpacking team-skills.tar.gz -> .
113+
[i] Bundle target: claude (1 dep(s), 3 file(s))
114+
[!] Bundle target 'claude' differs from project target 'copilot'
115+
[+] Unpacked 3 file(s) (verified)
116+
```
117+
118+
This is informational -- the files still extract. The warning helps users understand why their tool may not see the unpacked files and suggests the correct workflow.
119+
86120
## Bundle structure
87121

88122
The bundle mirrors the directory structure that `apm install` produces. It is not an intermediate format — extract it at the project root and the files land exactly where they belong.
@@ -212,7 +246,7 @@ The bundle includes a copy of `apm.lock.yaml` enriched with a `pack:` section. T
212246
```yaml
213247
pack:
214248
format: apm
215-
target: vscode
249+
target: copilot
216250
packed_at: '2025-07-14T09:30:00+00:00'
217251
lockfile_version: '1'
218252
generated_at: '2025-07-14T09:28:00+00:00'
@@ -263,6 +297,7 @@ apm unpack ./build/my-project-1.0.0.tar.gz --dry-run
263297
| `-o, --output` | `.` (current dir) | Target project directory |
264298
| `--skip-verify` | off | Skip completeness check against lockfile |
265299
| `--dry-run` | off | List files without writing |
300+
| `--force` | off | Deploy despite critical hidden-character findings |
266301

267302
### Behavior
268303

@@ -390,4 +425,7 @@ During unpack, verification found files listed in the bundle's lockfile that are
390425

391426
### Empty bundle
392427

393-
If `apm pack` produces zero files, check that your dependencies have `deployed_files` entries in `apm.lock.yaml`. This can happen if `apm install` completed but no integration files were deployed (e.g., the package has no prompts or agents for the active target).
428+
If `apm pack` produces zero files, check:
429+
430+
1. Your dependencies have `deployed_files` entries in `apm.lock.yaml`. This can happen if `apm install` completed but no integration files were deployed (e.g., the package has no prompts or agents for the active target).
431+
2. The `--target` filter matches where files were deployed. For example, if files are under `.github/` but you pack with `--target claude`, APM will remap `skills/` and `agents/` automatically. If no remappable files exist, the bundle will be empty. Try `--target all` or check `apm.lock.yaml` to see which prefixes your files use.

src/apm_cli/bundle/lockfile_enrichment.py

Lines changed: 102 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Lockfile enrichment for pack-time metadata."""
22

33
from datetime import datetime, timezone
4-
from typing import List
4+
from typing import Dict, List, Tuple
55

66
from ..deps.lockfile import LockFile
77

88

9-
# Must stay in sync with packer._TARGET_PREFIXES
9+
# Authoritative mapping of target names to deployed-file path prefixes.
1010
_TARGET_PREFIXES = {
1111
"copilot": [".github/"],
1212
"vscode": [".github/"],
@@ -16,11 +16,74 @@
1616
"all": [".github/", ".claude/", ".cursor/", ".opencode/"],
1717
}
1818

19+
# Cross-target path equivalences for skills/ and agents/ directories.
20+
# Only these two directory types are semantically identical across targets;
21+
# commands, instructions, hooks are target-specific and are NOT mapped.
22+
#
23+
# .github/ is the canonical interop prefix -- install always creates it, so
24+
# all non-github targets map FROM .github/. The copilot target additionally
25+
# maps FROM .claude/ for the common case of Claude-first projects packing
26+
# for Copilot. Cursor/opencode sources are niche; if someone publishes
27+
# skills exclusively under .cursor/, they must pack with --target cursor.
28+
_CROSS_TARGET_MAPS: Dict[str, Dict[str, str]] = {
29+
"claude": {
30+
".github/skills/": ".claude/skills/",
31+
".github/agents/": ".claude/agents/",
32+
},
33+
"vscode": {
34+
".claude/skills/": ".github/skills/",
35+
".claude/agents/": ".github/agents/",
36+
},
37+
"copilot": {
38+
".claude/skills/": ".github/skills/",
39+
".claude/agents/": ".github/agents/",
40+
},
41+
"cursor": {
42+
".github/skills/": ".cursor/skills/",
43+
".github/agents/": ".cursor/agents/",
44+
},
45+
"opencode": {
46+
".github/skills/": ".opencode/skills/",
47+
".github/agents/": ".opencode/agents/",
48+
},
49+
}
50+
51+
52+
def _filter_files_by_target(
53+
deployed_files: List[str], target: str
54+
) -> Tuple[List[str], Dict[str, str]]:
55+
"""Filter deployed file paths by target prefix, with cross-target mapping.
1956
20-
def _filter_files_by_target(deployed_files: List[str], target: str) -> List[str]:
21-
"""Filter deployed file paths by target prefix."""
57+
When files are deployed under one target prefix (e.g. ``.github/skills/``)
58+
but the pack target is different (e.g. ``claude``), skills and agents are
59+
remapped to the equivalent target path. Commands, instructions, and hooks
60+
are NOT remapped -- they are target-specific.
61+
62+
Returns:
63+
A tuple of ``(filtered_files, path_mappings)`` where *path_mappings*
64+
maps ``bundle_path -> disk_path`` for any file that was cross-target
65+
remapped. Direct matches have no entry in the dict.
66+
"""
2267
prefixes = _TARGET_PREFIXES.get(target, _TARGET_PREFIXES["all"])
23-
return [f for f in deployed_files if any(f.startswith(p) for p in prefixes)]
68+
direct = [f for f in deployed_files if any(f.startswith(p) for p in prefixes)]
69+
70+
path_mappings: Dict[str, str] = {}
71+
cross_map = _CROSS_TARGET_MAPS.get(target, {})
72+
if cross_map:
73+
direct_set = set(direct)
74+
for f in deployed_files:
75+
if f in direct_set:
76+
continue
77+
for src_prefix, dst_prefix in cross_map.items():
78+
if f.startswith(src_prefix):
79+
mapped = dst_prefix + f[len(src_prefix):]
80+
if mapped not in direct_set:
81+
direct.append(mapped)
82+
direct_set.add(mapped)
83+
path_mappings[mapped] = f
84+
break
85+
86+
return direct, path_mappings
2487

2588

2689
def enrich_lockfile_for_pack(
@@ -40,35 +103,54 @@ def enrich_lockfile_for_pack(
40103
Args:
41104
lockfile: The resolved lockfile to enrich.
42105
fmt: Bundle format (``"apm"`` or ``"plugin"``).
43-
target: Effective target used for packing (``"vscode"``, ``"claude"``, ``"all"``).
106+
target: Effective target used for packing (e.g. ``"copilot"``, ``"claude"``,
107+
``"all"``). The internal alias ``"vscode"`` is also accepted.
44108
45109
Returns:
46110
A YAML string with the ``pack:`` block followed by the original
47111
lockfile content.
48112
"""
49113
import yaml
50114

51-
pack_section = yaml.dump(
52-
{
53-
"pack": {
54-
"format": fmt,
55-
"target": target,
56-
"packed_at": datetime.now(timezone.utc).isoformat(),
57-
}
58-
},
59-
default_flow_style=False,
60-
sort_keys=False,
61-
)
62-
63115
# Build a filtered lockfile YAML: each dep's deployed_files is narrowed
64-
# to only the paths matching the pack target.
116+
# to only the paths matching the pack target (with cross-target mapping).
117+
all_mappings: Dict[str, str] = {}
65118
data = yaml.safe_load(lockfile.to_yaml())
66119
if data and "dependencies" in data:
67120
for dep in data["dependencies"]:
68121
if "deployed_files" in dep:
69-
dep["deployed_files"] = _filter_files_by_target(
122+
filtered, mappings = _filter_files_by_target(
70123
dep["deployed_files"], target
71124
)
125+
dep["deployed_files"] = filtered
126+
all_mappings.update(mappings)
127+
128+
# Build the pack: metadata section (after filtering so we know if mapping
129+
# occurred).
130+
pack_meta: Dict = {
131+
"format": fmt,
132+
"target": target,
133+
"packed_at": datetime.now(timezone.utc).isoformat(),
134+
}
135+
if all_mappings:
136+
# Record the source prefixes that were remapped so consumers know the
137+
# bundle paths differ from the original lockfile. Use the canonical
138+
# prefix keys from _CROSS_TARGET_MAPS rather than reverse-engineering
139+
# them from file paths.
140+
cross_map = _CROSS_TARGET_MAPS.get(target, {})
141+
used_src_prefixes = set()
142+
for original in all_mappings.values():
143+
for src_prefix in cross_map:
144+
if original.startswith(src_prefix):
145+
used_src_prefixes.add(src_prefix)
146+
break
147+
pack_meta["mapped_from"] = sorted(used_src_prefixes)
148+
149+
pack_section = yaml.dump(
150+
{"pack": pack_meta},
151+
default_flow_style=False,
152+
sort_keys=False,
153+
)
72154

73155
lockfile_yaml = yaml.dump(
74156
data, default_flow_style=False, sort_keys=False, allow_unicode=True

src/apm_cli/bundle/packer.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
import tarfile
66
from dataclasses import dataclass, field
77
from pathlib import Path
8-
from typing import List, Optional
8+
from typing import Dict, List, Optional
99

1010
from ..deps.lockfile import LockFile, get_lockfile_path, migrate_lockfile_if_needed
1111
from ..models.apm_package import APMPackage
1212
from ..core.target_detection import detect_target
13-
from .lockfile_enrichment import enrich_lockfile_for_pack, _TARGET_PREFIXES, _filter_files_by_target
13+
from .lockfile_enrichment import enrich_lockfile_for_pack, _filter_files_by_target
1414

1515

1616
@dataclass
@@ -20,6 +20,8 @@ class PackResult:
2020
bundle_path: Path
2121
files: List[str] = field(default_factory=list)
2222
lockfile_enriched: bool = False
23+
mapped_count: int = 0
24+
path_mappings: Dict[str, str] = field(default_factory=dict)
2325

2426

2527
def pack_bundle(
@@ -38,7 +40,7 @@ def pack_bundle(
3840
project_root: Root of the project containing ``apm.lock.yaml`` and ``apm.yml``.
3941
output_dir: Directory where the bundle will be created.
4042
fmt: Bundle format -- ``"apm"`` (default) or ``"plugin"``.
41-
target: Target filter -- ``"vscode"``, ``"claude"``, ``"all"``, or *None*
43+
target: Target filter -- ``"copilot"``, ``"claude"``, ``"all"``, or *None*
4244
(auto-detect from apm.yml / project structure).
4345
archive: If *True*, produce a ``.tar.gz`` and remove the directory.
4446
dry_run: If *True*, resolve the file list but write nothing to disk.
@@ -114,7 +116,7 @@ def pack_bundle(
114116
for dep in lockfile.get_all_dependencies():
115117
all_deployed.extend(dep.deployed_files)
116118

117-
filtered_files = _filter_files_by_target(all_deployed, effective_target)
119+
filtered_files, path_mappings = _filter_files_by_target(all_deployed, effective_target)
118120
# Deduplicate while preserving order
119121
seen = set()
120122
unique_files: List[str] = []
@@ -133,14 +135,16 @@ def pack_bundle(
133135
raise ValueError(
134136
f"Refusing to pack unsafe path from lockfile: {rel_path!r}"
135137
)
136-
abs_path = project_root / rel_path
138+
# For cross-target mapped files, verify the original (on-disk) path
139+
disk_path = path_mappings.get(rel_path, rel_path)
140+
abs_path = project_root / disk_path
137141
if not abs_path.resolve().is_relative_to(project_root_resolved):
138142
raise ValueError(
139-
f"Refusing to pack path that escapes project root: {rel_path!r}"
143+
f"Refusing to pack path that escapes project root: {disk_path!r}"
140144
)
141145
# deployed_files may reference directories (ending with /)
142146
if not abs_path.exists():
143-
missing.append(rel_path)
147+
missing.append(disk_path)
144148
if missing:
145149
raise ValueError(
146150
f"The following deployed files are missing on disk -- "
@@ -155,6 +159,8 @@ def pack_bundle(
155159
bundle_path=bundle_dir,
156160
files=unique_files,
157161
lockfile_enriched=True,
162+
mapped_count=len(path_mappings),
163+
path_mappings=path_mappings,
158164
)
159165

160166
# 5b. Scan files for hidden characters before bundling.
@@ -168,7 +174,8 @@ def pack_bundle(
168174

169175
_scan_findings_total = 0
170176
for rel_path in unique_files:
171-
src = project_root / rel_path
177+
disk_path = path_mappings.get(rel_path, rel_path)
178+
src = project_root / disk_path
172179
if src.is_symlink():
173180
continue
174181
if src.is_dir():
@@ -193,13 +200,21 @@ def pack_bundle(
193200
# 6. Build output directory
194201
bundle_dir = output_dir / f"{pkg_name}-{pkg_version}"
195202
bundle_dir.mkdir(parents=True, exist_ok=True)
203+
bundle_dir_resolved = bundle_dir.resolve()
196204

197205
# 7. Copy files preserving directory structure
198206
for rel_path in unique_files:
199-
src = project_root / rel_path
207+
# For cross-target mapped files, read from the original disk path
208+
disk_path = path_mappings.get(rel_path, rel_path)
209+
src = project_root / disk_path
200210
if src.is_symlink():
201211
continue # Never bundle symlinks
202212
dest = bundle_dir / rel_path
213+
# Defense-in-depth: verify mapped destination stays inside the bundle
214+
if not dest.resolve().is_relative_to(bundle_dir_resolved):
215+
raise ValueError(
216+
f"Refusing to write outside bundle directory: {rel_path!r}"
217+
)
203218
if src.is_dir():
204219
from ..security.gate import ignore_symlinks
205220
shutil.copytree(src, dest, dirs_exist_ok=True, ignore=ignore_symlinks)
@@ -215,6 +230,8 @@ def pack_bundle(
215230
bundle_path=bundle_dir,
216231
files=unique_files,
217232
lockfile_enriched=True,
233+
mapped_count=len(path_mappings),
234+
path_mappings=path_mappings,
218235
)
219236

220237
# 10. Archive if requested

0 commit comments

Comments
 (0)